customer creation now pointing to velocity

This commit is contained in:
2026-05-29 15:13:09 +02:00
parent 77b53a8bf0
commit 0bb63055a9
80 changed files with 1354 additions and 1984 deletions

View File

@@ -8,10 +8,12 @@ import org.springframework.context.annotation.Bean;
import com.google.gson.Gson; import com.google.gson.Gson;
import org.springframework.context.annotation.Import; import org.springframework.context.annotation.Import;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication @SpringBootApplication
@Import(RestConfiguration.class) @Import(RestConfiguration.class)
@EnableCaching @EnableCaching
@EnableAsync
public class TmApplication { public class TmApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@@ -15,7 +15,7 @@ public class CacheConfig {
@Bean @Bean
public CacheManager cacheManager() { public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("providerProducts"); CaffeineCacheManager cacheManager = new CaffeineCacheManager("providerProducts", "hotrechargeBalance");
cacheManager.setCaffeine(Caffeine.newBuilder() cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(24, TimeUnit.HOURS) .expireAfterWrite(24, TimeUnit.HOURS)
.maximumSize(10000)); .maximumSize(10000));

View File

@@ -0,0 +1,21 @@
package zw.qantra.tm.configs;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Getter
@Setter
@Configuration
@ConfigurationProperties(prefix = "velocity.api")
public class VelocityApiProperties {
private String baseUrl;
private String apiKey;
private String cancelUrl;
private String successUrl;
private String creditPhone;
private String creditAccount;
private String defaultAuthType;
private long pollIntervalMs;
}

View File

@@ -1,20 +1,12 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import zw.qantra.tm.domain.enums.ChargeConditionType;
import zw.qantra.tm.domain.enums.ChargeLabelEnum;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.Category;
import zw.qantra.tm.domain.models.Charge;
import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.models.IntegrationProcessor;
import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.services.ChargeConditionService; import zw.qantra.tm.domain.services.ChargeConditionService;
import zw.qantra.tm.domain.services.ChargeService; import zw.qantra.tm.domain.services.ChargeService;
import zw.qantra.tm.domain.services.IntegrationProcessorService; import zw.qantra.tm.domain.services.IntegrationProcessorService;
@@ -22,11 +14,7 @@ import zw.qantra.tm.domain.services.CategoryService;
import zw.qantra.tm.domain.services.PaymentProcessorService; import zw.qantra.tm.domain.services.PaymentProcessorService;
import zw.qantra.tm.domain.services.ProviderService; import zw.qantra.tm.domain.services.ProviderService;
import java.math.BigDecimal; @Slf4j
import java.util.Arrays;
import java.util.HashSet;
import java.util.List;
@RestController @RestController
@RequestMapping("/configs") @RequestMapping("/configs")
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -45,333 +33,4 @@ public class ConfigController {
return ResponseEntity.ok("Cache cleared"); return ResponseEntity.ok("Cache cleared");
} }
@GetMapping("/seed/live")
public ResponseEntity seedLiveConfig(){
// clear existing payment processors before seeding
paymentProcessorService.getPaymentProcessorRepository().deleteAll();
paymentProcessorService.getPaymentProcessorRepository().saveAll(Arrays.asList(
PaymentProcessor.builder()
.name("Ecocash")
.label("ECOCASH")
.type("GATEWAY")
.image("ecocash.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("EcoCash Phone Number")
.description("Pay using your mobile wallet")
.authType("REMOTE")
.build(),
PaymentProcessor.builder()
.name("Visa/MasterCard")
.label("MPGS")
.type("GATEWAY")
.image("visa-mastercard.png")
.accountFieldName("cardNumber")
.accountFieldLabel("Card Number")
.description("Pay using your international card")
.authType("WEB")
.build()
));
integrationProcessorService.getIntegrationProcessorRepository().deleteAll();
integrationProcessorService.getIntegrationProcessorRepository().saveAll(Arrays.asList(
IntegrationProcessor.builder()
.name("STEWARD")
.label("STEWARD")
.description("Steward Integration Processor")
.build()
));
// clear existing categories before seeding
categoryService.getCategoryRepository().deleteAll();
// seed categories
List<Category> categories = Arrays.asList(
Category.builder()
.name("Airtime")
.description("Airtime for local mobile networks")
.label("AIRTIME")
.build(),
Category.builder()
.name("Utilities")
.description("Council bills & rates")
.label("UTILITIES")
.build()
);
categoryService.getCategoryRepository().saveAll(categories);
// clear existing providers before seeding
providerService.getProviderRepository().deleteAll();
// seed providers
List<Provider> providers = Arrays.asList(
Provider.builder()
.description("Econet Airtime")
.clientId("econet_airtime")
.label("ECONET")
.category("AIRTIME")
.image("econet.png")
.externalId("78f1f497-c9eb-401c-b22c-884756e68e40")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.percentageCommission(6)
.build(),
Provider.builder()
.description("NetOne Airtime")
.clientId("netone_usd_airtime")
.label("NETONE")
.category("AIRTIME")
.image("netone.png")
.externalId("95d485a7-39c9-4559-8a27-6521969abe8f")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.percentageCommission(5.5)
.build(),
Provider.builder()
.description("Prepaid Zesa")
.clientId("zesa_prepaid_usd")
.label("ZESA")
.category("UTILITIES")
.image("zesa.png")
.externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Meter Number")
.percentageCommission(1.5)
.build(),
Provider.builder()
.description("Econet Broadband")
.clientId("econet_broadband_usd")
.label("ECONET_BROADBAND")
.category("AIRTIME")
.image("econet.png")
.externalId("287863e2-a791-4106-b629-79dadc9a6779")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.percentageCommission(6)
.build()
);
providerService.getProviderRepository().saveAll(providers);
// Clear existing charges and charge conditions before seeding
List<ChargeCondition> chargeConditions = Arrays.asList(
ChargeCondition.builder()
.name("Ecocash Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("ECOCASH")
.build(),
ChargeCondition.builder()
.name("Steward Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("STEWARD")
.build(),
ChargeCondition.builder()
.name("Mastercard Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("MPGS")
.build()
);
chargeService.getChargeRepository().deleteAll();
chargeConditionService.getChargeConditionRepository().deleteAll();
chargeConditionService.getChargeConditionRepository().saveAll(chargeConditions);
List<Charge> charges = Arrays.asList(
Charge.builder()
.description("Gateway fee")
.chargeLabel(ChargeLabelEnum.GATEWAY_FEE)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("1"))
.includes(new HashSet<>() {{
add(chargeConditions.get(0));
}})
.build(),
Charge.builder()
.description("Gateway fee")
.chargeLabel(ChargeLabelEnum.GATEWAY_FEE)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("2"))
.min(new BigDecimal("0.55"))
.includes(new HashSet<>() {{
add(chargeConditions.get(2));
}})
.build()
// Charge.builder()
// .description("Tax")
// .chargeLabel(ChargeLabelEnum.TAX)
// .currency(CurrencyType.USD)
// .percentageRate(new BigDecimal("2"))
// .max(new BigDecimal("10150"))
// .minimumAmount(new BigDecimal("5"))
// .maximumAmount(new BigDecimal("500000"))
// .includes(new HashSet<>() {{
// add(chargeConditions.get(0));
// add(chargeConditions.get(1));
// add(chargeConditions.get(2));
// }})
// .build()
);
chargeService.getChargeRepository().saveAll(charges);
return ResponseEntity.ok("Project configuration seeding complete");
}
@GetMapping("/seed")
public ResponseEntity seedConfig(){
// clear existing payment processors before seeding
paymentProcessorService.getPaymentProcessorRepository().deleteAll();
paymentProcessorService.getPaymentProcessorRepository().saveAll(Arrays.asList(
PaymentProcessor.builder()
.name("Ecocash")
.label("ECOCASH")
.type("GATEWAY")
.image("ecocash.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("EcoCash Phone Number")
.description("Pay using your mobile wallet")
.authType("REMOTE")
.build(),
PaymentProcessor.builder()
.name("Visa/MasterCard")
.label("MPGS")
.type("GATEWAY")
.image("visa-mastercard.png")
.accountFieldName("cardNumber")
.accountFieldLabel("Card Number")
.description("Pay using your international card")
.authType("WEB")
.build()
));
integrationProcessorService.getIntegrationProcessorRepository().deleteAll();
integrationProcessorService.getIntegrationProcessorRepository().saveAll(Arrays.asList(
IntegrationProcessor.builder()
.name("STEWARD")
.label("STEWARD")
.description("Steward Integration Processor")
.build()
));
// clear existing categories before seeding
categoryService.getCategoryRepository().deleteAll();
// seed categories
List<Category> categories = Arrays.asList(
Category.builder()
.name("Airtime")
.description("Airtime for local mobile networks")
.label("AIRTIME")
.build(),
Category.builder()
.name("Utilities")
.description("Council bills & rates")
.label("UTILITIES")
.build()
);
categoryService.getCategoryRepository().saveAll(categories);
// clear existing providers before seeding
providerService.getProviderRepository().deleteAll();
// seed providers
List<Provider> providers = Arrays.asList(
Provider.builder()
.description("Econet Airtime")
.clientId("econet_airtime")
.label("ECONET")
.category("AIRTIME")
.image("econet.png")
.externalId("78f1f497-c9eb-401c-b22c-884756e68e40")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.build(),
Provider.builder()
.description("NetOne Airtime")
.clientId("netone_usd_airtime")
.label("NETONE")
.category("AIRTIME")
.image("netone.png")
.externalId("95d485a7-39c9-4559-8a27-6521969abe8f")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.build(),
Provider.builder()
.description("Prepaid Zesa")
.clientId("powertel_zesa")
.label("ZESA")
.category("UTILITIES")
.image("zesa.png")
.externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Meter Number")
.build(),
Provider.builder()
.description("Econet Broadband")
.clientId("econet_broadband_usd")
.label("ECONET_BROADBAND")
.category("AIRTIME")
.image("econet.png")
.externalId("287863e2-a791-4106-b629-79dadc9a6779")
.integrationProcessorLabel("STEWARD")
.accountFieldName("Phone Number")
.build()
);
providerService.getProviderRepository().saveAll(providers);
// Clear existing charges and charge conditions before seeding
List<ChargeCondition> chargeConditions = Arrays.asList(
ChargeCondition.builder()
.name("Ecocash Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("ECOCASH")
.build(),
ChargeCondition.builder()
.name("Steward Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("STEWARD")
.build(),
ChargeCondition.builder()
.name("Mastercard Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("MPGS")
.build()
);
chargeService.getChargeRepository().deleteAll();
chargeConditionService.getChargeConditionRepository().deleteAll();
chargeConditionService.getChargeConditionRepository().saveAll(chargeConditions);
List<Charge> charges = Arrays.asList(
Charge.builder()
.description("Gateway fee")
.chargeLabel(ChargeLabelEnum.GATEWAY_FEE)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("1"))
.includes(new HashSet<>() {{
add(chargeConditions.get(0));
add(chargeConditions.get(1));
add(chargeConditions.get(2));
}})
.build(),
Charge.builder()
.description("Tax")
.chargeLabel(ChargeLabelEnum.TAX)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("2"))
.max(new BigDecimal("10150"))
.minimumAmount(new BigDecimal("5"))
.maximumAmount(new BigDecimal("500000"))
.includes(new HashSet<>() {{
add(chargeConditions.get(0));
add(chargeConditions.get(1));
add(chargeConditions.get(2));
}})
.build()
);
chargeService.getChargeRepository().saveAll(charges);
return ResponseEntity.ok("Project configuration seeding complete");
}
} }

View File

@@ -1,6 +1,5 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import io.nflow.engine.internal.dao.WorkflowInstanceDao;
import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.QueryWorkflowInstances; import io.nflow.engine.workflow.instance.QueryWorkflowInstances;
import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstance;
@@ -15,8 +14,8 @@ import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.dtos.*; import zw.qantra.tm.domain.dtos.*;
import zw.qantra.tm.domain.services.OtpService; import zw.qantra.tm.domain.services.OtpService;
import zw.qantra.tm.domain.services.UserService; import zw.qantra.tm.domain.services.UserService;
import zw.qantra.tm.domain.workflows.RegistrationWorkflow; import zw.qantra.tm.domain.services.processors.workflows.RegistrationWorkflow;
import zw.qantra.tm.domain.workflows.WorkflowUtils; import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.*; import java.util.*;

View File

@@ -1,6 +1,5 @@
package zw.qantra.tm.domain.controllers; package zw.qantra.tm.domain.controllers;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber; import com.google.i18n.phonenumbers.Phonenumber;
@@ -28,8 +27,8 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import zw.qantra.tm.domain.workflows.TransactionWorkflow; import zw.qantra.tm.domain.services.processors.workflows.TransactionWorkflow;
import zw.qantra.tm.domain.workflows.WorkflowUtils; import zw.qantra.tm.domain.services.processors.workflows.WorkflowUtils;
import zw.qantra.tm.utils.LogUtils; import zw.qantra.tm.utils.LogUtils;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
@@ -189,16 +188,10 @@ public class TransactonController {
throws ExecutionException, InterruptedException, NumberParseException { 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())
.setBusinessKey(formattedPhoneNumber) .setBusinessKey(transaction.getUserId())
.putStateVariable("confirmation", Utils.toJson(transaction)) .putStateVariable("confirmation", Utils.toJson(transaction))
.setNextActivation(DateTime.now()) .setNextActivation(DateTime.now())
.build(); .build();

View File

@@ -1,38 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.*;
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class CustomerDto {
@JsonProperty("doctype")
private String doctype;
@JsonProperty("customer_name")
private String customerName;
@JsonProperty("customer_group")
private String customerGroup;
@JsonProperty("territory")
private String territory;
@JsonProperty("customer_type")
private String customerType;
@JsonProperty("language")
private String language;
@JsonProperty("mobile_no")
private String mobileNo;
@JsonProperty("email_id")
private String emailId;
@JsonProperty("docstatus")
private Integer docstatus;
}

View File

@@ -1,27 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JournalEntryAccountDto {
private String doctype;
private String account;
@JsonProperty("debit_in_account_currency")
private BigDecimal debitInAccountCurrency;
@JsonProperty("credit_in_account_currency")
private BigDecimal creditInAccountCurrency;
@JsonProperty("cost_center")
private String costCenter;
@JsonProperty("party_type")
private String partyType;
private String party;
}

View File

@@ -1,22 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JournalEntryDto {
private String doctype;
private String company;
@JsonProperty("posting_date")
private String postingDate;
private int docstatus;
private List<JournalEntryAccountDto> accounts;
}

View File

@@ -1,40 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentEntryDto {
private String doctype;
private String company;
@JsonProperty("payment_type")
private String paymentType;
@JsonProperty("party_type")
private String partyType;
private String party;
@JsonProperty("paid_from")
private String paidFrom;
@JsonProperty("paid_to")
private String paidTo;
@JsonProperty("paid_amount")
private BigDecimal paidAmount;
@JsonProperty("received_amount")
private BigDecimal receivedAmount;
@JsonProperty("posting_date")
private String postingDate;
@JsonProperty("reference_no")
private String referenceNo;
@JsonProperty("reference_date")
private String referenceDate;
private List<PaymentReferenceDto> references;
private int docstatus;
}

View File

@@ -1,22 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PaymentReferenceDto {
@JsonProperty("reference_doctype")
private String referenceDoctype;
@JsonProperty("reference_name")
private String referenceName;
@JsonProperty("allocated_amount")
private BigDecimal allocatedAmount;
}

View File

@@ -1,39 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseInvoiceDto {
private String doctype;
private String supplier;
@JsonProperty("posting_date")
private String postingDate;
@JsonProperty("due_date")
private String dueDate;
private String company;
@JsonProperty("is_return")
private int isReturn;
private String currency;
@JsonProperty("conversion_rate")
private int conversionRate;
@JsonProperty("net_total")
private BigDecimal netTotal;
@JsonProperty("grand_total")
private BigDecimal grandTotal;
@JsonProperty("is_paid")
private int isPaid;
private int docstatus;
private List<PurchaseInvoiceItemDto> items;
private List<PurchaseTaxDto> taxes;
}

View File

@@ -1,26 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseInvoiceItemDto {
private String doctype;
@JsonProperty("item_code")
private String itemCode;
@JsonProperty("item_name")
private String itemName;
private BigDecimal qty;
private int rate;
private BigDecimal amount;
private String uom;
}

View File

@@ -1,29 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class PurchaseTaxDto {
private String doctype;
@JsonProperty("charge_type")
private String chargeType;
@JsonProperty("account_head")
private String accountHead;
private BigDecimal rate;
@JsonProperty("cost_center")
private String costCenter;
@JsonProperty("tax_amount")
private BigDecimal taxAmount;
private BigDecimal total;
private String description;
}

View File

@@ -1,25 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalesInvoiceDto {
private String doctype;
private String customer;
@JsonProperty("posting_date")
private String postingDate;
@JsonProperty("due_date")
private String dueDate;
private String company;
private int docstatus;
private List<SalesInvoiceItemDto> items;
}

View File

@@ -1,21 +0,0 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SalesInvoiceItemDto {
@JsonProperty("item_code")
private String itemCode;
private int qty;
private BigDecimal rate;
private BigDecimal amount;
}

View File

@@ -0,0 +1,18 @@
package zw.qantra.tm.domain.dtos.erp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VelocityCustomerDto {
private String name;
private String phone;
private String email;
private String billingCurrencyString;
private String companyIdString;
}

View File

@@ -0,0 +1,21 @@
package zw.qantra.tm.domain.dtos.erp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VelocityCustomerResponseDto {
private String id;
private String createdAt;
private String name;
private String phone;
private String email;
private String status;
private String billingCurrencyString;
private String companyIdString;
}

View File

@@ -0,0 +1,43 @@
package zw.qantra.tm.domain.dtos.erp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VelocitySalesOrderDto {
private String currencyCodeString;
private String orderDate;
private String dueDate;
private String notes;
private Boolean authorized;
private List<ItemDto> items;
private List<ChargeDto> charges;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ItemDto {
private String name;
private String itemCode;
private BigDecimal qty;
private BigDecimal unitPrice;
private BigDecimal amount;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class ChargeDto {
private BigDecimal amount;
}
}

View File

@@ -0,0 +1,67 @@
package zw.qantra.tm.domain.dtos.erp;
import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class VelocitySalesOrderResponseDto {
private String state;
private String status;
private BodyDto body;
private String message;
private String workflowId;
private String externalId;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class BodyDto {
private String id;
private String createdAt;
private Long orderDate;
private Long dueDate;
private BigDecimal paidAmount;
private BigDecimal changeAmount;
private BigDecimal outstandingAmount;
private String trace;
private Boolean authorized;
private String name;
private BigDecimal grandTotal;
private BigDecimal taxAmount;
private BigDecimal netTotal;
private String notes;
private BigDecimal totalQty;
private String status;
private String createdBy;
private String companyIdString;
private String customerIdString;
private CurrencyDto currency;
private String currencyCodeString;
}
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public static class CurrencyDto {
private String id;
private String createdAt;
private String name;
private String symbol;
private String code;
private BigDecimal rate;
private Boolean defaultCurrency;
}
}

View File

@@ -0,0 +1,29 @@
package zw.qantra.tm.domain.dtos.erp;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class VelocityTransactionDto {
private BigDecimal amount;
private String paymentProcessorLabel;
private String debitPhone;
private String debitRegion;
private String debitCurrency;
private String debitRef;
private String creditPhone;
private String creditRegion;
private String creditAccount;
private String type;
private String authType;
private String salesOrderId;
private String cancelUrl;
private String successUrl;
}

View File

@@ -42,7 +42,7 @@ public class Charge extends BaseEntity {
private TargetEvent targetEvent; // include / exclude private TargetEvent targetEvent; // include / exclude
@JsonIgnore @JsonIgnore
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable( @JoinTable(
name = "charge_include_products", name = "charge_include_products",
joinColumns = @JoinColumn(name = "charge_id"), joinColumns = @JoinColumn(name = "charge_id"),
@@ -51,7 +51,7 @@ public class Charge extends BaseEntity {
private Set<ChargeCondition> includes = new HashSet<>(); private Set<ChargeCondition> includes = new HashSet<>();
@JsonIgnore @JsonIgnore
@ManyToMany(fetch = FetchType.EAGER, cascade = CascadeType.ALL) @ManyToMany(fetch = FetchType.EAGER)
@JoinTable( @JoinTable(
name = "charge_exclude_products", name = "charge_exclude_products",
joinColumns = @JoinColumn(name = "charge_id"), joinColumns = @JoinColumn(name = "charge_id"),

View File

@@ -12,7 +12,7 @@ import java.util.List;
@Builder @Builder
@AllArgsConstructor @AllArgsConstructor
@NoArgsConstructor @NoArgsConstructor
public class ChargeCondition extends BaseEntity { public class ChargeCondition extends BaseEntity {
private String name; private String name;
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private ChargeConditionType type; private ChargeConditionType type;

View File

@@ -77,6 +77,11 @@ public class Transaction extends BaseEntity {
@Enumerated(EnumType.STRING) @Enumerated(EnumType.STRING)
private Status pollingStatus; private Status pollingStatus;
private String orderId;
private String orderTrace;
private String orderTransactionTrace;
private String erpSalesRef; private String erpSalesRef;
private String erpJournalRef; private String erpJournalRef;
private String erpSalesPaymentRef; private String erpSalesPaymentRef;

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
@Repository @Repository
public interface CategoryRepository extends JpaRepository<Category, UUID> { public interface CategoryRepository extends JpaRepository<Category, UUID> {
boolean existsByName(String name);
} }

View File

@@ -4,8 +4,11 @@ import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.models.ChargeCondition; import zw.qantra.tm.domain.models.ChargeCondition;
import java.util.Optional;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface ChargeConditionRepository extends JpaRepository<ChargeCondition, UUID> { public interface ChargeConditionRepository extends JpaRepository<ChargeCondition, UUID> {
boolean existsByCodes(String codes);
Optional<ChargeCondition> findByCodes(String codes);
} }

View File

@@ -2,13 +2,17 @@ package zw.qantra.tm.domain.repositories;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import zw.qantra.tm.domain.enums.ChargeLabelEnum;
import zw.qantra.tm.domain.enums.CurrencyType; import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.Charge; import zw.qantra.tm.domain.models.Charge;
import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
@Repository @Repository
public interface ChargeRepository extends JpaRepository<Charge, UUID> { public interface ChargeRepository extends JpaRepository<Charge, UUID> {
List<Charge> findAllByCurrency(CurrencyType currency); List<Charge> findAllByCurrency(CurrencyType currency);
boolean existsByChargeLabelAndCurrency(ChargeLabelEnum chargeLabel, CurrencyType currency);
boolean existsByChargeLabelAndCurrencyAndPercentageRate(ChargeLabelEnum chargeLabel, CurrencyType currency, BigDecimal percentageRate);
} }

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
@Repository @Repository
public interface IntegrationProcessorRepository extends JpaRepository<IntegrationProcessor, UUID> { public interface IntegrationProcessorRepository extends JpaRepository<IntegrationProcessor, UUID> {
boolean existsByName(String name);
} }

View File

@@ -8,4 +8,5 @@ import java.util.UUID;
@Repository @Repository
public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID> { public interface PaymentProcessorRepository extends JpaRepository<PaymentProcessor, UUID> {
boolean existsByLabel(String label);
} }

View File

@@ -11,4 +11,5 @@ import java.util.UUID;
public interface ProviderRepository extends JpaRepository<Provider, UUID> { public interface ProviderRepository extends JpaRepository<Provider, UUID> {
List<Provider> findByCategory(String category); List<Provider> findByCategory(String category);
Provider findByClientId(String clientId); Provider findByClientId(String clientId);
boolean existsByClientId(String clientId);
} }

View File

@@ -3,6 +3,7 @@ package zw.qantra.tm.domain.services;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.SimpleMailMessage;
import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Component @Component
@@ -10,6 +11,7 @@ public class EmailService {
@Autowired @Autowired
private JavaMailSender emailSender; private JavaMailSender emailSender;
@Async
public void sendSimpleMessage( public void sendSimpleMessage(
String to, String subject, String text) { String to, String subject, String text) {
SimpleMailMessage message = new SimpleMailMessage(); SimpleMailMessage message = new SimpleMailMessage();

View File

@@ -0,0 +1,56 @@
package zw.qantra.tm.domain.services;
import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import zw.qantra.tm.rest.RestService;
import java.math.BigDecimal;
import java.util.LinkedList;
import java.util.LinkedHashMap;
@Service
@RequiredArgsConstructor
public class HotRechargeBalanceService {
Logger logger = LoggerFactory.getLogger(HotRechargeBalanceService.class);
private final RestService restService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl;
@Cacheable(value = "hotrechargeBalance", key = "#token + '_' + #amount.toString()")
public boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
@SuppressWarnings("unchecked")
LinkedList<LinkedHashMap<String, Object>> responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap<String, Object> response = responseEntity.get(0);
logger.info("Balance response: {}", response);
if (response != null && response.containsKey("balance")) {
BigDecimal balance = new BigDecimal(response.get("balance").toString());
logger.info("Current balance: {}", balance);
return balance.compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
logger.error("Failed to check balance: {}", e.getMessage(), e);
return false;
}
}
@CacheEvict(value = "hotrechargeBalance", allEntries = true)
public void evictCache() {
logger.info("Evicting HotRecharge balance cache after successful transaction");
}
}

View File

@@ -26,7 +26,7 @@ public class HotRechargeTokenService {
private String hotAccessCode; private String hotAccessCode;
@Value("${hot.password}") @Value("${hot.password}")
private String hotPassword; private String hotPassword;
@Value("${hot.token.expiry.minutes:30}") @Value("${hot.token.expiry.minutes:25}")
private int tokenExpiryMinutes; private int tokenExpiryMinutes;
private String cachedToken; private String cachedToken;

View File

@@ -26,4 +26,17 @@ public class SettingService {
return false; return false;
} }
} }
public String getStringSetting(String key) {
try {
Setting setting = settingRepository.findBySettingName(key);
if (setting != null) {
return setting.getSettingValue();
}
return "";
} catch (Exception exception) {
exception.printStackTrace();
return "";
}
}
} }

View File

@@ -0,0 +1,313 @@
package zw.qantra.tm.domain.services;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.web.util.UriComponentsBuilder;
import zw.qantra.tm.domain.dtos.RegisterRequest;
import zw.qantra.tm.domain.dtos.erp.*;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.repositories.TransactionRepository;
import zw.qantra.tm.configs.VelocityApiProperties;
import zw.qantra.tm.exceptions.AlreadyExistsException;
import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.*;
@Service
@Slf4j
@RequiredArgsConstructor
public class VelocityPaymentService {
private final TransactionRepository transactionRepository;
private final ObjectMapper objectMapper;
private final RestService restService;
private final VelocityApiProperties velocityApiProperties;
private final SettingService settingService;
private static final String VELOCITY_API_CREATE_ORDER_PATH = "/sales-orders";
private static final String VELOCITY_API_INITIATE_PAYMENT_PATH = "/transactions";
private static final String VELOCITY_API_POLL_PAYMENT_PATH = "/transactions/poll/{traceId}";
private static final String VELOCITY_API_UPDATE_WORKFLOW_PATH = "/sales-orders/update-workflow/{traceId}";
private static final String VELOCITY_API_CUSTOMERS = "/customers";
public VelocityCustomerResponseDto createCustomer(RegisterRequest registerRequest) {
VelocityCustomerDto payload = VelocityCustomerDto.builder()
.name(registerRequest.getFirstName() + " " + registerRequest.getLastName())
.email(registerRequest.getEmail())
.phone(registerRequest.getPhone())
.billingCurrencyString(CurrencyType.USD.name())
.companyIdString(settingService.getStringSetting("VELOCITY_COMPANY_ID"))
.build();
try {
LinkedHashMap responseMap = makeRequest(HttpMethod.POST, VELOCITY_API_CUSTOMERS, payload);
String status = (String) responseMap.get("status");
String id = (String) responseMap.get("id");
log.info("Customer created with status: {}, id: {}", status, id);
if("PENDING".equalsIgnoreCase(status) && id != null) {
LinkedHashMap authResponse = makeRequest(HttpMethod.PUT,
VELOCITY_API_CUSTOMERS + "/authorize/" + id, null);
String workflowStatus = (String) authResponse.get("status");
if("finished".equalsIgnoreCase(workflowStatus)) {
return Utils.deepCopy(
authResponse.get("body"), VelocityCustomerResponseDto.class);
}
}else {
List<String> errors = (List<String>) responseMap.get("errors");
if(errors != null && !errors.isEmpty()){
String errorMessage = errors.get(0);
if(errorMessage.contains("already exists")) {
log.warn("Customer already exists in Velocity: {}", errorMessage);
throw new AlreadyExistsException(errorMessage); // or handle as needed
}
log.error("Error creating customer: {}", errorMessage);
throw new IllegalStateException("Error creating customer: " + errorMessage);
}
}
return null;
} catch (Exception e) {
log.error("Error creating customer: {}", e.getMessage(), e);
throw new IllegalStateException("Error creating customer: " + e.getMessage(), e);
}
}
public Transaction processPayment(Transaction transaction) {
Transaction orderTransaction = createSalesOrder(transaction);
return initiatePayment(orderTransaction);
}
public Transaction createSalesOrder(Transaction transaction) {
BigDecimal amount = transaction.getTotalAmount();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
VelocitySalesOrderDto payload = VelocitySalesOrderDto.builder()
.currencyCodeString(transaction.getDebitCurrency().name())
.orderDate(LocalDate.now().format(fmt))
.dueDate(LocalDate.now().plusDays(7).format(fmt))
.notes(transaction.getReference())
.authorized(Boolean.TRUE)
.items(Collections.singletonList(VelocitySalesOrderDto.ItemDto.builder()
.name(transaction.getBillName())
.itemCode("VLT-" + transaction.getProviderLabel())
.qty(BigDecimal.ONE)
.unitPrice(amount)
.amount(amount)
.build()))
.charges(Collections.emptyList())
.build();
try {
LinkedHashMap responseMap = makeRequest(HttpMethod.POST, VELOCITY_API_CREATE_ORDER_PATH, payload);
VelocitySalesOrderResponseDto response = Utils.deepCopy(
responseMap, VelocitySalesOrderResponseDto.class);
String workflowStatus = response.getStatus();
VelocitySalesOrderResponseDto.BodyDto body = response.getBody();
String orderStatus = body.getStatus();
String velocityOrderId = body.getId();
String velocityOrderTrace = body.getTrace();
String velocityOrderName = body.getName();
if ("manual".equalsIgnoreCase(workflowStatus) && "UNPAID".equalsIgnoreCase(orderStatus)) {
transaction.setOrderId(velocityOrderId);
transaction.setOrderTrace(velocityOrderTrace);
transaction.setErpSalesRef(velocityOrderName);
return transaction;
}
log.error("Unexpected sales order response from Velocity: {}", response);
throw new IllegalStateException("Unexpected sales order response from Velocity");
} catch (Exception e) {
log.error("Error creating sales order: {}", e.getMessage(), e);
throw new IllegalStateException("Error creating sales order: " + e.getMessage(), e);
}
}
private Transaction initiatePayment(Transaction transaction) {
String paymentProcessorLabel = Objects.equals(transaction.getPaymentProcessorLabel(), "MPGS")
? "VMC" : transaction.getPaymentProcessorLabel();
VelocityTransactionDto payload = VelocityTransactionDto.builder()
.salesOrderId(transaction.getOrderId())
.amount(transaction.getTotalAmount())
.debitPhone(transaction.getDebitPhone())
.debitRegion("ZW")
.type("REQUEST")
.paymentProcessorLabel(paymentProcessorLabel)
.debitCurrency(transaction.getDebitCurrency().name())
.debitRef(transaction.getErpSalesRef())
.creditPhone(velocityApiProperties.getCreditPhone())
.creditRegion("ZW")
.creditAccount(velocityApiProperties.getCreditAccount())
.authType(velocityApiProperties.getDefaultAuthType())
.build();
if ("MPGS".equalsIgnoreCase(transaction.getPaymentProcessorLabel())) {
payload.setCancelUrl(velocityApiProperties.getCancelUrl() + "/" + transaction.getOrderId());
payload.setSuccessUrl(velocityApiProperties.getSuccessUrl() + "/" + transaction.getOrderId());
}
try {
LinkedHashMap response = makeRequest(
HttpMethod.POST, VELOCITY_API_INITIATE_PAYMENT_PATH, payload);
LinkedHashMap body = (LinkedHashMap) response.get("body");
String transactionStatus = (String) body.get("status");
String tranTrace = (String) body.get("trace");
String transactionName = (String) body.get("name");
String redirectUrl = body.containsKey("redirectUrl") ? (String) body.get("redirectUrl") : null;
if ("SUCCESS".equalsIgnoreCase(transactionStatus)) {
transaction.setErpSalesPaymentRef(transactionName);
transaction.setOrderTransactionTrace(tranTrace);
transaction.setTargetUrl(redirectUrl);
return transaction;
}
return null;
} catch (Exception e) {
log.error("Error initiating payment: {}", e.getMessage(), e);
throw new IllegalStateException("Error initiating payment: " + e.getMessage(), e);
}
}
public Transaction pollPaymentStatus(Transaction transaction) {
LinkedHashMap response = makeRequest(
HttpMethod.PUT,
VELOCITY_API_POLL_PAYMENT_PATH.replace("{traceId}", transaction.getOrderTransactionTrace()),
null
);
LinkedHashMap body = (LinkedHashMap) response.get("body");
transaction.setPaymentStatus(Status.valueOf((String) body.get("paymentStatus")));
transaction.setPollingStatus(Status.valueOf((String) body.get("pollStatus")));
transaction.setDebitRef((String) body.get("debitRef"));
transaction.setPaymentProcessorRef((String) body.get("paymentProcessorRef"));
return transaction;
}
@Async
public void updateSalesOrderWorkflow(Transaction transaction) {
LinkedHashMap response = makeRequest(
HttpMethod.PUT,
VELOCITY_API_UPDATE_WORKFLOW_PATH.replace("{traceId}", transaction.getOrderTrace()),
null
);
LinkedHashMap body = (LinkedHashMap) response.get("body");
LinkedHashMap salesOrderNode = (LinkedHashMap) body.get("salesOrder");
String status = (String) salesOrderNode.get("status");
if (!"PAID".equals(status)) {
log.info("Sales order status is not PAID. Status: {}", status);
}
}
private LinkedHashMap makeRequest(HttpMethod method, String path, Object payload) {
long startNs = System.nanoTime();
ResponseEntity<LinkedHashMap> response;
try {
response = restService.exchange(
buildUrl(path),
method,
new HttpEntity<>(payload, headers()),
LinkedHashMap.class
);
} catch (Exception ex) {
log.error("Velocity API request failed. method={}, path={}", method, path, ex);
throw new IllegalStateException("Velocity API request failed: " + ex.getMessage(), ex);
}
LinkedHashMap body = response.getBody();
if (!response.getStatusCode().is2xxSuccessful()) {
throw new IllegalStateException("Velocity API request failed with status: " + response.getStatusCode());
}
return body;
}
private JsonNode resolveBody(JsonNode response) {
JsonNode body = response.path("body");
return body.isMissingNode() || body.isNull() ? response : body;
}
private String normalize(String value) {
return value == null ? null : value.toUpperCase(Locale.ROOT);
}
private Double numberValue(JsonNode node, String key, Double fallback) {
JsonNode value = node.get(key);
if (value == null || value.isNull()) {
return fallback;
}
return value.asDouble();
}
private HttpHeaders headers() {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (velocityApiProperties.getApiKey() != null && !velocityApiProperties.getApiKey().isBlank()) {
headers.set("X-API-Key", velocityApiProperties.getApiKey());
}
headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
return headers;
}
private String buildUrl(String path) {
return UriComponentsBuilder.fromUriString(velocityApiProperties.getBaseUrl())
.path(path)
.build()
.toUriString();
}
private String text(JsonNode node, String... keys) {
for (String key : keys) {
JsonNode value = node.get(key);
if (value != null && !value.isNull()) {
return value.asText();
}
}
return null;
}
private boolean isTerminal(String status) {
if (status == null) {
return false;
}
String normalized = status.toUpperCase();
return normalized.equals("SUCCESS")
|| normalized.equals("PAID")
|| normalized.equals("FAILED")
|| normalized.equals("DECLINED")
|| normalized.equals("CANCELLED");
}
private void sleep() {
try {
Thread.sleep(Math.max(200L, velocityApiProperties.getPollIntervalMs()));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while polling Velocity payment status", e);
}
}
}

View File

@@ -2,6 +2,7 @@ package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService; import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService; import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
@@ -11,7 +12,8 @@ public class EconetBundlesConfirmationHotProcessor extends NetoneConfirmationHot
public EconetBundlesConfirmationHotProcessor( public EconetBundlesConfirmationHotProcessor(
AdditionalDataService additionalDataService, AdditionalDataService additionalDataService,
HotRechargeTokenService hotRechargeTokenService, HotRechargeTokenService hotRechargeTokenService,
HotRechargeBalanceService hotRechargeBalanceService,
RestService restService) { RestService restService) {
super(additionalDataService, hotRechargeTokenService, restService); super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService);
} }
} }

View File

@@ -2,6 +2,7 @@ package zw.qantra.tm.domain.services.processors.confirmations.hotrecharge;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.services.AdditionalDataService; import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService; import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
@@ -11,7 +12,8 @@ public class EconetConfirmationHotProcessor extends NetoneConfirmationHotProcess
public EconetConfirmationHotProcessor( public EconetConfirmationHotProcessor(
AdditionalDataService additionalDataService, AdditionalDataService additionalDataService,
HotRechargeTokenService hotRechargeTokenService, HotRechargeTokenService hotRechargeTokenService,
HotRechargeBalanceService hotRechargeBalanceService,
RestService restService) { RestService restService) {
super(additionalDataService, hotRechargeTokenService, restService); super(additionalDataService, hotRechargeTokenService, hotRechargeBalanceService, restService);
} }
} }

View File

@@ -10,6 +10,7 @@ import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData; import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService; import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService; import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
@@ -23,9 +24,10 @@ import java.util.*;
@RequiredArgsConstructor @RequiredArgsConstructor
@Slf4j @Slf4j
public class NetoneConfirmationHotProcessor implements TransactionProcessorInterface { public class NetoneConfirmationHotProcessor implements TransactionProcessorInterface {
private final AdditionalDataService additionalDataService; protected final AdditionalDataService additionalDataService;
private final HotRechargeTokenService hotRechargeTokenService; protected final HotRechargeTokenService hotRechargeTokenService;
private final RestService restService; protected final HotRechargeBalanceService hotRechargeBalanceService;
protected final RestService restService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@@ -35,7 +37,7 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
String token = hotRechargeTokenService.getToken(); String token = hotRechargeTokenService.getToken();
if (!checkBalance(token, transaction.getAmount())) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92"); transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
@@ -82,26 +84,4 @@ public class NetoneConfirmationHotProcessor implements TransactionProcessorInter
return transaction; return transaction;
} }
private boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
LinkedList responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap response = (LinkedHashMap) responseEntity.get(0);
log.info("Response: {}", response);
if (response != null && response.containsKey("balance")) {
Object balance = response.get("balance");
log.info("Current balance: {}", balance);
return (new BigDecimal(balance.toString())).compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
log.error("Failed to check balance: {}", e.getMessage(), e);
return false;
}
}
} }

View File

@@ -14,6 +14,7 @@ import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Provider; import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.AdditionalDataService; import zw.qantra.tm.domain.services.AdditionalDataService;
import zw.qantra.tm.domain.services.HotRechargeBalanceService;
import zw.qantra.tm.domain.services.HotRechargeTokenService; import zw.qantra.tm.domain.services.HotRechargeTokenService;
import zw.qantra.tm.domain.services.ProviderService; import zw.qantra.tm.domain.services.ProviderService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
@@ -33,6 +34,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
private final AdditionalDataService additionalDataService; private final AdditionalDataService additionalDataService;
private final ProviderService providerService; private final ProviderService providerService;
private final HotRechargeTokenService hotRechargeTokenService; private final HotRechargeTokenService hotRechargeTokenService;
private final HotRechargeBalanceService hotRechargeBalanceService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@@ -50,7 +52,7 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return transaction; return transaction;
} }
if (!checkBalance(token, transaction.getAmount())) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92"); transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
@@ -103,28 +105,6 @@ public class ZesaConfirmationHotProcessor implements TransactionProcessorInterfa
return additionalDataList; return additionalDataList;
} }
private boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
LinkedList responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap response = (LinkedHashMap) responseEntity.get(0);
logger.info("Response: " + response);
if (response != null && response.containsKey("balance")) {
Object balance = response.get("balance");
logger.info("Current balance: " + balance);
return (new BigDecimal(balance.toString())).compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
logger.error("Failed to check balance: " + e.getMessage(), e);
return false;
}
}
private Map<String, Object> checkCustomer(String token, Transaction transaction) { private Map<String, Object> checkCustomer(String token, Transaction transaction) {
try { try {
Provider provider = providerService.getProviderRepository() Provider provider = providerService.getProviderRepository()

View File

@@ -31,6 +31,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
private final SettingService settingService; private final SettingService settingService;
private final HotRechargeTokenService hotRechargeTokenService; private final HotRechargeTokenService hotRechargeTokenService;
private final ProviderService providerService; private final ProviderService providerService;
private final HotRechargeBalanceService hotRechargeBalanceService;
@Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}") @Value("${hot.api.url:https://ssl.hot.co.zw/api/v3}")
private String hotApiUrl; private String hotApiUrl;
@@ -53,7 +54,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){ if(settingService.getBooleanSetting("SIMULATE_HOT_SUCCESS")){
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("responseCode"); transaction.setResponseCode("responseCode");
transaction.setCreditRef("creditRef"); transaction.setCreditRef("Simulated Integration Success");
transaction.setErrorMessage(null); transaction.setErrorMessage(null);
return transaction; return transaction;
} }
@@ -69,7 +70,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
return transaction; return transaction;
} }
if (!checkBalance(token, transaction.getAmount())) { if (!hotRechargeBalanceService.checkBalance(token, transaction.getAmount())) {
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("92"); transaction.setResponseCode("92");
transaction.setErrorMessage("There's a technical issue on our end. Please try again later."); transaction.setErrorMessage("There's a technical issue on our end. Please try again later.");
@@ -86,6 +87,7 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
Boolean successful = (Boolean) rechargeResponse.get("successful"); Boolean successful = (Boolean) rechargeResponse.get("successful");
if (successful != null && successful) { if (successful != null && successful) {
hotRechargeBalanceService.evictCache();
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00"); transaction.setResponseCode("00");
transaction.setCreditRef(rechargeResponse.get("rechargeId").toString()); transaction.setCreditRef(rechargeResponse.get("rechargeId").toString());
@@ -123,29 +125,6 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
} }
private boolean checkBalance(String token, BigDecimal amount) {
try {
String balanceUrl = hotApiUrl + "/account/balance/4";
HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(token);
@SuppressWarnings("unchecked")
LinkedList<LinkedHashMap<String, Object>> responseEntity = restService.getAs(balanceUrl, headers, LinkedList.class);
LinkedHashMap<String, Object> response = responseEntity.get(0);
logger.info("Balance response: {}", response);
if (response != null && response.containsKey("balance")) {
BigDecimal balance = new BigDecimal(response.get("balance").toString());
logger.info("Current balance: {}", balance);
return balance.compareTo(amount) > 0;
}
return false;
} catch (Exception e) {
logger.error("Failed to check balance: {}", e.getMessage(), e);
return false;
}
}
private LinkedHashMap<String, Object> performRecharge(String token, Transaction transaction) { private LinkedHashMap<String, Object> performRecharge(String token, Transaction transaction) {
try { try {
String rechargeUrl = hotApiUrl + "/products/recharge"; String rechargeUrl = hotApiUrl + "/products/recharge";
@@ -159,7 +138,12 @@ public class HotRechargeIntegrationProcessor implements TransactionProcessorInte
request.put("Target", transaction.getCreditAccount()); request.put("Target", transaction.getCreditAccount());
request.put("Amount", transaction.getAmount()); request.put("Amount", transaction.getAmount());
// do this for zesa // for zesa skip this
if(!providerService.getProviderId(transaction.getBillClientId()).equals("41")) {
request.put("CustomerSMS", "%COMPANYNAME% topped up your account with $%AMOUNT%.");
}
// do this for zesa only
if(providerService.getProviderId(transaction.getBillClientId()).equals("41")) { if(providerService.getProviderId(transaction.getBillClientId()).equals("41")) {
List<Map<String, String>> rechargeOptions = new ArrayList<>(); List<Map<String, String>> rechargeOptions = new ArrayList<>();
if (transaction.getDebitPhone() != null) { if (transaction.getDebitPhone() != null) {

View File

@@ -57,6 +57,14 @@ public class StewardIntegrationProcessor implements TransactionProcessorInterfac
return transaction; return transaction;
} }
if(settingService.getBooleanSetting("SIMULATE_STEWARD_SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("responseCode");
transaction.setCreditRef("Simulated Integration Success");
transaction.setErrorMessage(null);
return transaction;
}
logger.info("processing integration"); logger.info("processing integration");
String token = restService.getMerchantToken(); String token = restService.getMerchantToken();

View File

@@ -19,6 +19,7 @@ import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.SettingService; import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
@@ -29,6 +30,7 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
private final RestService restService; private final RestService restService;
private final TransactionService transactionService; private final TransactionService transactionService;
private final SettingService settingService; private final SettingService settingService;
private final VelocityPaymentService velocityPaymentService;
@Value("${sbz.aggregator.encryption-key}") @Value("${sbz.aggregator.encryption-key}")
private String encryptionKey; private String encryptionKey;
@@ -51,78 +53,10 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
return transaction; return transaction;
} }
String token = restService.getMerchantToken();
// send total less gateway charges
BigDecimal totalAmount = transaction.getAmount()
.add(transaction.getTax())
.add(transaction.getCharge())
.add(transaction.getGatewayCharge());
String concat = clientId+
encryptionKey+
transaction.getDebitPhone()+
totalAmount;
String hex = getHash(concat);
SdkActionDto sdkActionDto = SdkActionDto.builder()
.clientId(clientId)
.clientSecret(clientSecret)
.phone(transaction.getDebitPhone())
.amount(totalAmount.toString())
.currency(transaction.getDebitCurrency().toString())
.hash(hex)
.authType("REMOTE")
.billerReference(transaction.getReference())
.action("PAYMENT")
.sandbox(true)
.paymentProcessorLabel("ECOCASH")
.responseUrl("https://peakapi.qantra.co.zw")
.build();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
headers.set("Content-Type", "application/json");
try { try {
LinkedHashMap response = restService.postAs(url + "/merchant/sdk/action", transaction = velocityPaymentService.processPayment(transaction);
sdkActionDto, headers, LinkedHashMap.class);
LinkedHashMap body = (LinkedHashMap) response.get("body");
transaction.setSdkActionId((String) body.get("uid"));
if(body.get("status").equals("SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
transaction.setPaymentProcessorRef((String) transactionData.get("debitReference"));
// getting omni channel rrn from additional data
java.util.List<LinkedHashMap> additionalData =
(java.util.List<LinkedHashMap>) transactionData.get("additionalData");
if (additionalData != null) {
for (LinkedHashMap dataItem : additionalData) {
if ("Transaction Reference".equals(dataItem.get("name"))) {
transaction.setDebitRef((String) dataItem.get("value"));
break;
}
}
}
transactionService.save(transaction);
}else{
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
String errorMessage = "Problem processing payment. Please try again.";
if(body.get("transaction") != null) {
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
errorMessage = (String) transactionData.get("errorMessage");
}
transaction.setErrorMessage(errorMessage);
}
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
@@ -149,42 +83,15 @@ public class EcocashRemotePaymentProcessor implements TransactionProcessorInterf
return transaction; return transaction;
} }
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
headers.set("Content-Type", "application/json");
try { try {
LinkedHashMap response = restService.getAs(url + "/merchant/sdk/reference/" + transaction.getReference(), transaction = velocityPaymentService.pollPaymentStatus(transaction);
headers, LinkedHashMap.class); transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
LinkedHashMap body = (LinkedHashMap) response.get("body"); transactionService.save(transaction);
// only update tran status if polling returns a success
if(body.get("status").equals("SUCCESS")){
transaction.setStatus(Status.SUCCESS);
transaction.setResponseCode("00");
transaction.setTargetUrl((String) body.get("targetUrl"));
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
transaction.setPaymentProcessorRef((String) transactionData.get("debitReference"));
// getting omni channel rrn from additional data
java.util.List<LinkedHashMap> additionalData =
(java.util.List<LinkedHashMap>) transactionData.get("additionalData");
if (additionalData != null) {
for (LinkedHashMap dataItem : additionalData) {
if ("Transaction Reference".equals(dataItem.get("name"))) {
transaction.setDebitRef((String) dataItem.get("value"));
break;
}
}
}
transactionService.save(transaction);
}
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");

View File

@@ -12,6 +12,7 @@ import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.SettingService; import zw.qantra.tm.domain.services.SettingService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface; import zw.qantra.tm.domain.services.processors.TransactionProcessorInterface;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
@@ -33,6 +34,7 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
private final RestService restService; private final RestService restService;
private final TransactionService transactionService; private final TransactionService transactionService;
private final SettingService settingService; private final SettingService settingService;
private final VelocityPaymentService velocityPaymentService;
@Value("${sbz.aggregator.encryption-key}") @Value("${sbz.aggregator.encryption-key}")
private String encryptionKey; private String encryptionKey;
@@ -48,70 +50,25 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
@Override @Override
public Object process(Transaction transaction) throws Exception { public Object process(Transaction transaction) throws Exception {
logger.info("processing transaction"); logger.info("processing transaction");
String token = restService.getMerchantToken();
// send total less gateway charges if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) {
BigDecimal totalAmount = transaction.getAmount() transaction.setStatus(Status.SUCCESS);
.add(transaction.getTax()) transaction.setResponseCode("00");
.add(transaction.getCharge()) transaction.setPaymentProcessorRef(RandomStringUtils.randomNumeric(10));
.add(transaction.getGatewayCharge()); transaction.setDebitRef(RandomStringUtils.randomNumeric(10));
return transaction;
String concat = clientId+ }
encryptionKey+
transaction.getCreditPhone()+
totalAmount;
String hex = getHash(concat);
SdkActionDto sdkActionDto = SdkActionDto.builder()
.clientId(clientId)
.clientSecret(clientSecret)
.channel("api")
.phone(transaction.getCreditPhone())
.amount(totalAmount.toString())
.currency(transaction.getDebitCurrency().toString())
.hash(hex)
.authType("WEB")
.billerReference(transaction.getReference())
.action("PAYMENT")
.sandbox(true)
.paymentProcessorLabel("MPGS")
.responseUrl("https://stewardbank.co.zw")
.returnUrl(returnUrl + "/" + transaction.getReference())
.build();
HttpHeaders headers = new HttpHeaders();
headers.set("Authorization", "Bearer " + token);
headers.set("Content-Type", "application/json");
try { try {
LinkedHashMap response = restService.postAs(url + "/merchant/sdk/action", // velocity expects VMC
sdkActionDto, headers, LinkedHashMap.class); transaction = velocityPaymentService.processPayment(transaction);
logger.info("response: {}", Utils.toJson(response));
LinkedHashMap body = (LinkedHashMap) response.get("body");
transaction.setSdkActionId((String) body.get("uid"));
// if status is not failed, set the transaction to pending and send customer to the target url
if(!body.get("status").equals("FAILED")){
transaction.setStatus(Status.PENDING);
transaction.setResponseCode("00");
transaction.setTargetUrl((String) body.get("targetUrl"));
}else{
transaction.setStatus(Status.FAILED);
transaction.setResponseCode("01");
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
if (transactionData != null) {
transaction.setErrorMessage((String) transactionData.get("errorMessage"));
} else {
transaction.setErrorMessage(body.get("errorMessage").toString());
}
}
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again");
} }
return transactionService.save(transaction); return transactionService.save(transaction);
@@ -120,56 +77,29 @@ public class MPGSWebPaymentProcessor implements TransactionProcessorInterface {
@Override @Override
public Object poll(Transaction transaction) throws Exception { public Object poll(Transaction transaction) throws Exception {
logger.info("polling transaction"); logger.info("polling transaction");
String token = restService.getMerchantToken();
HttpHeaders headers = new HttpHeaders(); if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")) {
headers.set("Authorization", "Bearer " + token); transaction.setStatus(Status.SUCCESS);
headers.set("Content-Type", "application/json"); transaction.setResponseCode("00");
transaction.setTargetUrl("/home");
return transaction;
}
try { try {
LinkedHashMap response = restService.getAs(url + "/merchant/sdk/mpgs/poll/" + transaction.getSdkActionId(), transaction = velocityPaymentService.pollPaymentStatus(transaction);
headers, LinkedHashMap.class);
logger.info("response: {}", Utils.toJson(response));
LinkedHashMap body = (LinkedHashMap) response.get("body");
// todo: REMOVE THIS
if(settingService.getBooleanSetting("SIMULATE_MPGS_SUCCESS")){
body.put("status", "SUCCESS");
LinkedHashMap hashMap = new LinkedHashMap();
hashMap.put("creditReference", RandomStringUtils.randomNumeric(10));
body.put("transaction", hashMap);
}
// only update tran status if polling returns a success
if(!body.get("status").equals("SUCCESS")){
logger.error(body.get("errorMessage").toString());
transaction.setStatus(Status.FAILED);
transaction.setResponseCode(body.get("responseCode").toString());
transaction.setErrorMessage(body.get("errorMessage").toString());
transactionService.save(transaction);
return transaction;
}
transaction.setStatus(Status.SUCCESS); transaction.setStatus(Status.SUCCESS);
transaction.setPaymentStatus(Status.SUCCESS);
transaction.setResponseCode("00"); transaction.setResponseCode("00");
transaction.setTargetUrl((String) body.get("targetUrl"));
try {
LinkedHashMap transactionData = (LinkedHashMap) body.get("transaction");
transaction.setDebitRef((String) transactionData.get("debitReference"));
} catch (Exception e) {
logger.info("Notification not yet posted at the bank");
}
transactionService.save(transaction); transactionService.save(transaction);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace();
logger.error("Network error during payment processing: {}", e.getMessage(), e); logger.error("Network error during payment processing: {}", e.getMessage(), e);
transaction.setStatus(Status.FAILED); transaction.setStatus(Status.FAILED);
transaction.setResponseCode("99"); transaction.setResponseCode("99");
transaction.setErrorMessage("Network error: " + e.getMessage()); transaction.setSystemErrorMessage("Network error: " + e.getMessage());
transaction.setErrorMessage("Payment failed. Please try again");
} }
return transaction; return transaction;

View File

@@ -1,8 +1,7 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.curated.CronWorkflow; import io.nflow.engine.workflow.curated.CronWorkflow;
import io.nflow.engine.workflow.curated.State;
import io.nflow.engine.workflow.definition.*; import io.nflow.engine.workflow.definition.*;
import io.nflow.engine.workflow.instance.WorkflowInstance; import io.nflow.engine.workflow.instance.WorkflowInstance;
import io.nflow.engine.workflow.instance.WorkflowInstanceAction; import io.nflow.engine.workflow.instance.WorkflowInstanceAction;
@@ -18,7 +17,6 @@ import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.utils.LogUtils; import zw.qantra.tm.utils.LogUtils;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.sql.Timestamp;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.nflow.engine.workflow.curated.State; import io.nflow.engine.workflow.curated.State;
@@ -11,8 +11,8 @@ import org.joda.time.DateTime;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.workflows.handlers.HandlerInterface; import zw.qantra.tm.domain.services.processors.workflows.handlers.HandlerInterface;
import zw.qantra.tm.domain.workflows.handlers.WorkflowHandlerFactory; import zw.qantra.tm.domain.services.processors.workflows.handlers.WorkflowHandlerFactory;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
@@ -15,7 +15,7 @@ import io.nflow.engine.workflow.definition.NextAction;
import io.nflow.engine.workflow.definition.StateExecution; import io.nflow.engine.workflow.definition.StateExecution;
import io.nflow.engine.workflow.definition.WorkflowDefinition; import io.nflow.engine.workflow.definition.WorkflowDefinition;
import zw.qantra.tm.domain.dtos.*; import zw.qantra.tm.domain.dtos.*;
import zw.qantra.tm.domain.dtos.erp.CustomerDto; import zw.qantra.tm.domain.dtos.erp.VelocityCustomerResponseDto;
import zw.qantra.tm.domain.models.Otp; import zw.qantra.tm.domain.models.Otp;
import zw.qantra.tm.domain.models.User; import zw.qantra.tm.domain.models.User;
import zw.qantra.tm.domain.services.EmailService; import zw.qantra.tm.domain.services.EmailService;
@@ -25,6 +25,8 @@ import io.nflow.engine.workflow.curated.State;
import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolation;
import jakarta.validation.Validator; import jakarta.validation.Validator;
import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.exceptions.AlreadyExistsException;
import zw.qantra.tm.rest.RestService; import zw.qantra.tm.rest.RestService;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
@@ -46,21 +48,8 @@ public class RegistrationWorkflow extends WorkflowDefinition {
private RestService restService; private RestService restService;
@Autowired @Autowired
private EmailService emailService; private EmailService emailService;
@Autowired
@Value("${infobip.url}") private VelocityPaymentService velocityPaymentService;
private String infobipUrl;
@Value("${infobip.apikey}")
private String infobipApiKey;
@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;
public static final String TYPE = "registrationWorkflow"; public static final String TYPE = "registrationWorkflow";
@@ -190,75 +179,17 @@ public class RegistrationWorkflow extends WorkflowDefinition {
public NextAction updateErp(StateExecution execution) { public NextAction updateErp(StateExecution execution) {
RegisterRequest registerRequest = execution.getVariable("registerRequest", RegisterRequest.class); RegisterRequest registerRequest = execution.getVariable("registerRequest", RegisterRequest.class);
// login to erp
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
headers.add("Accept", "application/json");
Map<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try { try {
ResponseEntity response = restService.postAsEntity( VelocityCustomerResponseDto customer = velocityPaymentService.createCustomer(registerRequest);
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
return NextAction.retryAfter(DateTime.now().plusSeconds(30), "Failed to login to ERP: " + e.getMessage());
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
// check if customer exists first
String customerName = registerRequest.getFirstName() + " " + registerRequest.getLastName();
try {
LinkedHashMap checkResponse = restService.getAs(erpnextUrl + "/v2/document/Customer" +
"?fields=[\"name\"]&filters=[[\"name\", \"=\", " +
"\"" + customerName + "\"]]", headers, LinkedHashMap.class);
List<LinkedHashMap> dataList = (List) checkResponse.get("data");
if(!dataList.isEmpty()){
User user = userService.fetchUserByUsername(registerRequest.getUsername());
user.setErpCustomerRef(customerName);
userService.getUserRepository().save(user);
return NextAction.stopInState(DONE, "Customer already exists in ERP. Ignoring update: " +
registerRequest.getUsername());
}
// if not proceed
CustomerDto customerDto = CustomerDto.builder()
.doctype("Customer")
.customerName(customerName)
.customerGroup("Commercial")
.territory("All Territories")
.customerType("Individual")
.language("en")
.mobileNo(registerRequest.getPhone())
.emailId(registerRequest.getEmail())
.docstatus(1)
.build();
LinkedHashMap response = restService.postAs(
erpnextUrl + "/v2/document/Customer", customerDto, headers, LinkedHashMap.class);
LinkedHashMap data = (LinkedHashMap) response.get("data");
String customerId = (String) data.get("name");
User user = userService.fetchUserByUsername(registerRequest.getUsername()); User user = userService.fetchUserByUsername(registerRequest.getUsername());
user.setErpCustomerRef(customerId); user.setErpCustomerRef(customer.getId());
userService.getUserRepository().save(user); userService.getUserRepository().save(user);
} catch (AlreadyExistsException e){
log.error("Customer already exists in ERP: {}", e.getMessage(), e);
return NextAction.stopInState(DONE, "ERP updated successfully for: " + registerRequest.getUsername());
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
return NextAction.retryAfter(DateTime.now().plusMinutes(5), "Failed to update ERP: " + e.getMessage()); return NextAction.retryAfter(DateTime.now().plusMinutes(5), "Failed to update ERP: " + e.getMessage());
} }

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.nflow.engine.workflow.curated.State; import io.nflow.engine.workflow.curated.State;
@@ -12,8 +12,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
import zw.qantra.tm.domain.workflows.handlers.HandlerInterface; import zw.qantra.tm.domain.services.processors.workflows.handlers.HandlerInterface;
import zw.qantra.tm.domain.workflows.handlers.WorkflowHandlerFactory; import zw.qantra.tm.domain.services.processors.workflows.handlers.WorkflowHandlerFactory;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;

View File

@@ -1,16 +1,14 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import io.nflow.engine.service.WorkflowInstanceService; import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.instance.WorkflowInstanceFactory; import io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.joda.time.DateTime; import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.context.ApplicationListener; import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent; import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.utils.Utils;
@Component @Component
@RequiredArgsConstructor @RequiredArgsConstructor

View File

@@ -1,25 +1,17 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import io.nflow.engine.service.WorkflowInstanceService;
import io.nflow.engine.workflow.curated.State; import io.nflow.engine.workflow.curated.State;
import io.nflow.engine.workflow.definition.NextAction; import io.nflow.engine.workflow.definition.NextAction;
import io.nflow.engine.workflow.definition.StateExecution; import io.nflow.engine.workflow.definition.StateExecution;
import io.nflow.engine.workflow.definition.WorkflowDefinition; import io.nflow.engine.workflow.definition.WorkflowDefinition;
import io.nflow.engine.workflow.definition.WorkflowStateType; import io.nflow.engine.workflow.definition.WorkflowStateType;
import io.nflow.engine.workflow.instance.WorkflowInstanceFactory;
import org.joda.time.DateTime;
import org.joda.time.Instant;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.VelocityPaymentService;
import zw.qantra.tm.domain.workflows.handlers.HandlerInterface; import zw.qantra.tm.domain.services.processors.workflows.handlers.*;
import zw.qantra.tm.domain.workflows.handlers.WorkflowHandlerFactory;
import zw.qantra.tm.exceptions.ApiException;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.io.IOException; import java.io.IOException;
@@ -32,13 +24,23 @@ public class TransactionWorkflow extends WorkflowDefinition {
@Autowired @Autowired
private WorkflowHandlerFactory workflowHandlerFactory; private WorkflowHandlerFactory workflowHandlerFactory;
@Autowired @Autowired
private TransactionService transactionService;
@Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
@Autowired @Autowired
private WorkflowInstanceService workflowInstanceService; private VelocityPaymentService velocityPaymentService;
@Autowired @Autowired
private WorkflowInstanceFactory workflowInstanceFactory; private CalculateChargesHandler calculateChargesHandler;
@Autowired
private CleanupHandler cleanupHandler;
@Autowired
private RecipientsUpdateHandler recipientsUpdateHandler;
@Autowired
private GatewayPaymentHandler gatewayPaymentHandler;
@Autowired
private ConfirmHandler confirmHandler;
@Autowired
private PollStatusHandler pollStatusHandler;
@Autowired
private IntegrationHandler integrationHandler;
public static final String TYPE = "transactionWorkflow"; public static final String TYPE = "transactionWorkflow";
@@ -79,9 +81,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);
HandlerInterface handlerInterface = transaction = calculateChargesHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("calculateCharges");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
@@ -98,9 +98,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);
HandlerInterface handlerInterface = transaction = (Transaction) cleanupHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("cleanup");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
@@ -118,9 +116,7 @@ public class TransactionWorkflow extends WorkflowDefinition {
Transaction transaction = objectMapper.readValue(json, Transaction.class); Transaction transaction = objectMapper.readValue(json, Transaction.class);
transaction.setWorkflowId(execution.getWorkflowInstanceId()); transaction.setWorkflowId(execution.getWorkflowInstanceId());
HandlerInterface handlerInterface = transaction = (Transaction) confirmHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("confirm");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(RECIPIENTS_UPDATE, "Recipients updated successfully"); return NextAction.moveToState(RECIPIENTS_UPDATE, "Recipients updated successfully");
} catch (Exception e) { } catch (Exception e) {
@@ -134,9 +130,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);
HandlerInterface handlerInterface = recipientsUpdateHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("recipientsUpdate");
handlerInterface.process(transaction);
return NextAction.moveToState(CONFIRM_SUCCESS, "Transaction confirmed successfully"); return NextAction.moveToState(CONFIRM_SUCCESS, "Transaction confirmed successfully");
} }
@@ -152,9 +146,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);
HandlerInterface handlerInterface = transaction = (Transaction) gatewayPaymentHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("gatewayPayment");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(GATEWAY_PAYMENT_SUCCESS, "Gateway payment initiated"); return NextAction.moveToState(GATEWAY_PAYMENT_SUCCESS, "Gateway payment initiated");
@@ -165,25 +157,7 @@ public class TransactionWorkflow extends WorkflowDefinition {
} }
public void gatewayPaymentSuccess(StateExecution execution) { public void gatewayPaymentSuccess(StateExecution execution) {
try { log.info("Gateway payment initiation complete");
LinkedHashMap map = execution.getVariable("body", LinkedHashMap.class);
byte[] json = objectMapper.writeValueAsBytes(map);
Transaction transaction = objectMapper.readValue(json, Transaction.class);
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder()
.setType(SalesInvoiceWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis())
.setBusinessKey(execution.getBusinessKey())
.putStateVariable("body", Utils.toJson(transaction))
.setNextActivation(DateTime.now())
.build();
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
log.info("Gateway payment initiation complete");
}catch (Exception e) {
e.printStackTrace();
}
} }
public NextAction pollStatus(StateExecution execution) throws IOException { public NextAction pollStatus(StateExecution execution) throws IOException {
@@ -193,9 +167,7 @@ public class TransactionWorkflow extends WorkflowDefinition {
Transaction transaction = objectMapper.readValue(json, Transaction.class); Transaction transaction = objectMapper.readValue(json, Transaction.class);
try { try {
HandlerInterface handlerInterface = transaction = (Transaction) pollStatusHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("pollStatus");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(POLL_STATUS_SUCCESS, "Polling complete"); return NextAction.moveToState(POLL_STATUS_SUCCESS, "Polling complete");
@@ -216,9 +188,7 @@ public class TransactionWorkflow extends WorkflowDefinition {
Transaction transaction = objectMapper.readValue(json, Transaction.class); Transaction transaction = objectMapper.readValue(json, Transaction.class);
try { try {
HandlerInterface handlerInterface = transaction = (Transaction) integrationHandler.process(transaction);
(HandlerInterface) workflowHandlerFactory.getWorkflowHandler("integration");
transaction = (Transaction) handlerInterface.process(transaction);
execution.setVariable("body", Utils.toJson(transaction)); execution.setVariable("body", Utils.toJson(transaction));
return NextAction.moveToState(INTEGRATION_STATUS_SUCCESS, "Integration request successful"); return NextAction.moveToState(INTEGRATION_STATUS_SUCCESS, "Integration request successful");
@@ -235,15 +205,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);
var instance = workflowInstanceFactory.newWorkflowInstanceBuilder() velocityPaymentService.updateSalesOrderWorkflow(transaction);
.setType(PurchaseInvoiceWorkflow.TYPE)
.setExternalId(transaction.getUserId() + "-" + new Instant().getMillis())
.setBusinessKey(execution.getBusinessKey())
.putStateVariable("body", Utils.toJson(transaction))
.setNextActivation(DateTime.now())
.build();
long workflowId = workflowInstanceService.insertWorkflowInstance(instance);
log.info("Transaction workflow complete"); log.info("Transaction workflow complete");
}catch (Exception e) { }catch (Exception e) {

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows; package zw.qantra.tm.domain.services.processors.workflows;
import java.util.Arrays; import java.util.Arrays;
import java.util.EnumSet; import java.util.EnumSet;
@@ -6,8 +6,6 @@ import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.CompletableFuture; 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 io.nflow.engine.workflow.instance.WorkflowInstanceAction;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -8,7 +8,6 @@ import zw.qantra.tm.domain.models.Charge;
import zw.qantra.tm.domain.models.ChargeCondition; import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.ChargeService; import zw.qantra.tm.domain.services.ChargeService;
import zw.qantra.tm.domain.services.processors.handlers.LogicHandler;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; import java.math.RoundingMode;

View File

@@ -1,10 +1,9 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import com.google.i18n.phonenumbers.NumberParseException; import com.google.i18n.phonenumbers.NumberParseException;
import com.google.i18n.phonenumbers.PhoneNumberUtil; import com.google.i18n.phonenumbers.PhoneNumberUtil;
import com.google.i18n.phonenumbers.Phonenumber; import com.google.i18n.phonenumbers.Phonenumber;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.aspectj.apache.bcel.generic.RET;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;

View File

@@ -1,10 +1,8 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import zw.qantra.tm.domain.enums.RequestType;
import zw.qantra.tm.domain.enums.Status; import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.AdditionalData;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.models.TransactionEvent; import zw.qantra.tm.domain.models.TransactionEvent;
import zw.qantra.tm.domain.services.AdditionalDataService; import zw.qantra.tm.domain.services.AdditionalDataService;
@@ -12,13 +10,8 @@ import zw.qantra.tm.domain.services.TransactionEventService;
import zw.qantra.tm.domain.services.TransactionService; import zw.qantra.tm.domain.services.TransactionService;
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.*;
import zw.qantra.tm.utils.Utils; import zw.qantra.tm.utils.Utils;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.UUID;
@Service("confirm") @Service("confirm")
@RequiredArgsConstructor @RequiredArgsConstructor
public class ConfirmHandler implements HandlerInterface { public class ConfirmHandler implements HandlerInterface {
@@ -43,11 +36,11 @@ public class ConfirmHandler implements HandlerInterface {
// provider.getLabel() + "_" + // provider.getLabel() + "_" +
// provider.getIntegrationProcessorLabel(); // provider.getIntegrationProcessorLabel();
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(
transaction.getConfirmationProcessorLabel());
try { try {
TransactionProcessorInterface transactionProcessorInterface =
(TransactionProcessorInterface) paymentProcessorFactory.getPaymentProcessor(
transaction.getConfirmationProcessorLabel());
// MAIN TRANSACTION LOGIC! // MAIN TRANSACTION LOGIC!
transaction = (Transaction) transactionProcessorInterface.process(transaction); transaction = (Transaction) transactionProcessorInterface.process(transaction);
transaction.setConfirmationStatus(transaction.getStatus()); transaction.setConfirmationStatus(transaction.getStatus());

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -14,7 +14,7 @@ import zw.qantra.tm.exceptions.ApiException;
@Service("gatewayPayment") @Service("gatewayPayment")
@RequiredArgsConstructor @RequiredArgsConstructor
public class PaymentHandler implements HandlerInterface { public class GatewayPaymentHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(TransactionService.class); Logger logger = LoggerFactory.getLogger(TransactionService.class);
private final PaymentProcessorFactory paymentProcessorFactory; private final PaymentProcessorFactory paymentProcessorFactory;
@@ -26,6 +26,10 @@ public class PaymentHandler implements HandlerInterface {
@Override @Override
public Object process(Transaction incomingTransaction) throws Exception { public Object process(Transaction incomingTransaction) throws Exception {
Transaction transaction = incomingTransaction; Transaction transaction = incomingTransaction;
if(incomingTransaction == null) {
throw new ApiException("Transaction not found");
}
if(incomingTransaction.getId() != null){ if(incomingTransaction.getId() != null){
transaction = transactionService.getTransactionRepository().findById(incomingTransaction.getId()).orElseThrow(() -> transaction = transactionService.getTransactionRepository().findById(incomingTransaction.getId()).orElseThrow(() ->
new ApiException("Transaction not found for ID: " + incomingTransaction.getId())); new ApiException("Transaction not found for ID: " + incomingTransaction.getId()));
@@ -56,6 +60,7 @@ public class PaymentHandler implements HandlerInterface {
// MAIN TRANSACTION LOGIC! // MAIN TRANSACTION LOGIC!
transactionProcessorInterface.process(transaction); transactionProcessorInterface.process(transaction);
transaction.setPaymentStatus(transaction.getStatus()); transaction.setPaymentStatus(transaction.getStatus());
transaction.setPollingStatus(Status.PENDING);
}catch (Exception e){ }catch (Exception e){
e.printStackTrace(); e.printStackTrace();

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -6,7 +6,6 @@ import zw.qantra.tm.domain.enums.Status;
import zw.qantra.tm.domain.models.Recipient; import zw.qantra.tm.domain.models.Recipient;
import zw.qantra.tm.domain.models.Transaction; import zw.qantra.tm.domain.models.Transaction;
import zw.qantra.tm.domain.services.RecipientService; import zw.qantra.tm.domain.services.RecipientService;
import zw.qantra.tm.domain.services.processors.handlers.LogicHandler;
import java.util.List; import java.util.List;

View File

@@ -1,4 +1,4 @@
package zw.qantra.tm.domain.workflows.handlers; package zw.qantra.tm.domain.services.processors.workflows.handlers;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@@ -1,154 +0,0 @@
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.JournalEntryAccountDto;
import zw.qantra.tm.domain.dtos.erp.JournalEntryDto;
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("journalEntry")
@RequiredArgsConstructor
public class JournalEntryHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(JournalEntryHandler.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;
@Value("${erpnext.accounts.gatewayexpense}")
private String gatewayExpenseAccount;
@Value("${erpnext.accounts.accountsreceivable}")
private String accountsReceivableAccount;
@Value("${erpnext.accounts.cash}")
private String cashAccount;
@Value("${erpnext.cost.center}")
private String costCenter;
@Override
public Transaction process(Transaction transaction) {
logger.info("Processing journal entry for transaction: {}", transaction.getReference());
if(transaction.getErpJournalRef() != null)
return transaction;
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.JOURNAL_ENTRY.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
if(transaction.getStatus() == Status.SUCCESS){
try {
logger.info("Creating journal 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<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
JournalEntryAccountDto expenseEntry = JournalEntryAccountDto.builder()
.doctype("Journal Entry Account")
.account(gatewayExpenseAccount)
.debitInAccountCurrency(transaction.getGatewayCharge())
.costCenter(costCenter)
.build();
JournalEntryAccountDto cashAccEntry = JournalEntryAccountDto.builder()
.doctype("Journal Entry Account")
.account(cashAccount)
.creditInAccountCurrency(transaction.getGatewayCharge())
.build();
JournalEntryDto journalEntryDto = JournalEntryDto.builder()
.doctype("Journal Entry")
.company(erpnextCompany)
.postingDate(transaction.getCreatedAt().toLocalDate().toString())
.docstatus(1)
.accounts(Arrays.asList(cashAccEntry, expenseEntry))
.build();
try {
LinkedHashMap response = restService.postAs(
erpnextUrl + "/v2/document/Journal Entry", journalEntryDto, headers, LinkedHashMap.class);
LinkedHashMap data = (LinkedHashMap) response.get("data");
String transactionId = (String) data.get("name");
transaction.setErpJournalRef(transactionId);
transactionEvent.setStatus(Status.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to create journal entry in ERPNext");
}
logger.info("journal entry created successfully in ERPNext");
} catch (Exception e) {
logger.error("Failed to create journal 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 journal entry creation", transaction.getStatus());
}
transactionEventService.save(transactionEvent);
return transactionRepository.save(transaction);
}
}

View File

@@ -1,160 +0,0 @@
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.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.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.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.util.*;
@Service("salesPayment")
@RequiredArgsConstructor
public class PaymentEntryHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(PaymentEntryHandler.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;
@Value("${erpnext.accounts.accountsreceivable}")
private String accountsReceivableAccount;
@Value("${erpnext.accounts.cash}")
private String cashAccount;
@Override
public Transaction process(Transaction transaction) {
logger.info("Processing payment entry for transaction: {}", transaction.getReference());
if(transaction.getErpSalesPaymentRef() != null)
return transaction;
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.SALES_PAYMENT.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
if(transaction.getPaymentStatus() == Status.SUCCESS){
try {
logger.info("Creating 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<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
String erpCustomerRef = "";
User user = userService.getUserRepository().findById(UUID.fromString(transaction.getUserId())).orElse(null);
erpCustomerRef = (user == null) ? "Anonymous" : user.getErpCustomerRef();
BigDecimal amount = transaction.getAmount().add(transaction.getGatewayCharge());
PaymentReferenceDto paymentReferenceDto = PaymentReferenceDto.builder()
.referenceDoctype("Sales Invoice")
.referenceName(transaction.getErpSalesRef())
.allocatedAmount(amount)
.build();
PaymentEntryDto paymentEntryDto = PaymentEntryDto.builder()
.doctype("Payment Entry")
.company(erpnextCompany)
.docstatus(1)
.paymentType("Receive")
.party(erpCustomerRef)
.partyType("Customer")
.paidFrom(accountsReceivableAccount)
.paidTo(cashAccount)
.paidAmount(amount)
.receivedAmount(amount)
.referenceNo(transaction.getId().toString())
.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.setErpSalesPaymentRef(transactionId);
transactionEvent.setStatus(Status.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to create payment entry in ERPNext");
}
logger.info("payment entry created successfully in ERPNext");
} catch (Exception e) {
logger.error("Failed to create 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 payment entry creation", transaction.getStatus());
}
transactionEventService.save(transactionEvent);
return transactionRepository.save(transaction);
}
}

View File

@@ -1,187 +0,0 @@
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.PurchaseInvoiceDto;
import zw.qantra.tm.domain.dtos.erp.PurchaseInvoiceItemDto;
import zw.qantra.tm.domain.dtos.erp.PurchaseTaxDto;
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.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.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static java.time.OffsetDateTime.now;
@Service("purchaseInvoice")
@RequiredArgsConstructor
public class PurchaseInvoiceHandler implements HandlerInterface {
Logger logger = LoggerFactory.getLogger(PurchaseInvoiceHandler.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.cost.center}")
private String erpnextCostCenter;
@Value("${erpnext.accounts.commission}")
private String erpnextCommissionAccount;
@Override
public Transaction process(Transaction transaction) {
logger.info("Processing purchase invoice for transaction: {}", transaction.getReference());
if(transaction.getErpPurchaseRef() != null)
return transaction;
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.PURCHASE_INVOICE.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
if(transaction.getErpPurchaseRef() != null)
return transaction;
if(transaction.getStatus() == Status.SUCCESS){
try {
logger.info("Creating purchase invoice in ERPNext for successful transaction");
// login to erp
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
headers.add("Accept", "application/json");
Map<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
PurchaseInvoiceItemDto purchaseInvoiceItemDto = PurchaseInvoiceItemDto.builder()
.doctype("Purchase Invoice Item")
.uom("Nos")
.itemCode(transaction.getProviderLabel())
.itemName(transaction.getProviderLabel())
.qty(transaction.getAmount())
.rate(1)
.amount(transaction.getAmount())
.build();
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
BigDecimal commissionPercentage = BigDecimal.valueOf(provider.getPercentageCommission())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_EVEN);
BigDecimal commission = transaction.getAmount().multiply(commissionPercentage)
.setScale(2, RoundingMode.HALF_EVEN);
BigDecimal amount = transaction.getAmount().subtract(commission);
PurchaseTaxDto purchaseTaxDto = PurchaseTaxDto.builder()
.doctype("Purchase Taxes and Charges")
.chargeType("On Net Total")
.accountHead(erpnextCommissionAccount)
.rate(BigDecimal.valueOf(provider.getPercentageCommission()).negate())
.costCenter(erpnextCostCenter)
.taxAmount(commission.negate())
.total(amount)
.description("Commission")
.build();
PurchaseInvoiceDto purchaseInvoiceDto = PurchaseInvoiceDto.builder()
.doctype("Purchase Invoice")
.supplier("Steward Bank")
.company(erpnextCompany)
.postingDate(now().toLocalDate().toString())
.dueDate(now().toLocalDate().plusDays(1).toString())
.docstatus(1)
.isReturn(0)
.isPaid(0)
.currency(transaction.getDebitCurrency().toString())
.conversionRate(1)
.netTotal(amount)
.grandTotal(amount)
.items(Collections.singletonList(purchaseInvoiceItemDto))
.taxes(Collections.singletonList(purchaseTaxDto))
.build();
try {
LinkedHashMap response = restService.postAs(
erpnextUrl + "/v2/document/Purchase Invoice", purchaseInvoiceDto, headers, LinkedHashMap.class);
LinkedHashMap data = (LinkedHashMap) response.get("data");
String transactionId = (String) data.get("name");
transaction.setErpPurchaseRef(transactionId);
transactionEvent.setStatus(Status.SUCCESS);
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to create purchase invoice in ERPNext");
}
logger.info("purchase invoice created successfully in ERPNext");
} catch (Exception e) {
logger.error("Failed to create purchase 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 purchase invoice creation", transaction.getStatus());
}
transactionEventService.save(transactionEvent);
return transactionRepository.save(transaction);
}
}

View File

@@ -1,173 +0,0 @@
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.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.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.math.BigDecimal;
import java.math.RoundingMode;
import java.nio.charset.StandardCharsets;
import java.util.*;
@Service("purchaseInvoicePayment")
@RequiredArgsConstructor
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;
@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());
if(transaction.getErpPurchasePaymentRef() != null)
return transaction;
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.PURCHASE_PAYMENT.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
if(transaction.getErpPurchasePaymentRef() != null)
return transaction;
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<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
Provider provider = providerService.getProviderRepository()
.findByClientId(transaction.getBillClientId());
BigDecimal commissionPercentage = BigDecimal.valueOf(provider.getPercentageCommission())
.divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_EVEN);
BigDecimal commission = transaction.getAmount().multiply(commissionPercentage)
.setScale(2, RoundingMode.HALF_EVEN);
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);
}
}

View File

@@ -1,146 +0,0 @@
package zw.qantra.tm.domain.workflows.handlers.erp;
import lombok.RequiredArgsConstructor;
import org.joda.time.LocalDate;
import org.joda.time.LocalDateTime;
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 org.springframework.transaction.annotation.Transactional;
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.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("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 TransactionService transactionService;
@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
@Transactional
public Transaction process(Transaction transaction) {
logger.info("Processing sales invoice for transaction: {}", transaction.getReference());
if(transaction.getErpSalesRef() != null)
return transaction;
TransactionEvent transactionEvent = TransactionEvent.builder()
.transactionId(transaction.getId().toString())
.event(RequestType.SALES_INVOICE.name())
.status(Status.PROCESSING)
.build();
transactionEventService.save(transactionEvent);
try {
logger.info("Creating sales invoice in ERPNext for successful transaction");
// login to erp
HttpHeaders headers = new HttpHeaders();
headers.add("Content-Type", "application/json");
headers.add("Accept", "application/json");
Map<String, String> loginRequest = Map.of(
"usr", erpnextUsername,
"pwd", erpnextPassword);
String sid = "";
try {
ResponseEntity response = restService.postAsEntity(
erpnextUrl + "/method/login", loginRequest, headers, LinkedHashMap.class);
sid = response.getHeaders().getFirst("Set-Cookie");
} catch (Exception e) {
e.printStackTrace();
throw new ApiException("Failed to login to ERPNext");
}
// create customer in erp
String basicAuth = "Basic " + Base64.getEncoder().encodeToString(
(erpnextApiKey + ":" + erpnextApiSecret).getBytes(StandardCharsets.UTF_8));
headers.add("Authorization", basicAuth);
headers.add("Cookie", sid);
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().plusDays(1).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 transactionService.save(transaction);
}
}

View File

@@ -0,0 +1,15 @@
package zw.qantra.tm.exceptions;
public class AlreadyExistsException extends RuntimeException {
private String message;
public AlreadyExistsException(String message) {
this.message = message;
}
@Override
public String getMessage() {
return message;
}
}

View File

@@ -232,8 +232,8 @@ public class RestService {
return this.restTemplate.getForObject(path, clazz); return this.restTemplate.getForObject(path, clazz);
} }
public <T> ResponseEntity<?> exchangeGetNoAuth(String path, HttpEntity<?> httpEntity, Class<?> clazz, String pathVariable) { public ResponseEntity exchange(String path, HttpMethod method, HttpEntity<Object> objectHttpEntity, Class jsonNodeClass) {
return this.restTemplate.exchange(path, HttpMethod.GET, httpEntity, clazz, pathVariable); return this.restTemplate.exchange(path, method, objectHttpEntity, jsonNodeClass);
} }
private String prettyPrintJson(String json) { private String prettyPrintJson(String json) {

View File

@@ -0,0 +1,48 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.Category;
import zw.qantra.tm.domain.repositories.CategoryRepository;
import java.util.Arrays;
import java.util.List;
/**
* Seeds Category entities.
* Priority: 3rd - Must run after IntegrationProcessorSeeder and before ProviderSeeder.
* Only seeds entities that don't already exist (checked by name).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class CategorySeeder implements Seeder {
private final CategoryRepository categoryRepository;
@Override
public void seed() {
List<Category> categories = Arrays.asList(
Category.builder()
.name("Airtime")
.description("Airtime for local mobile networks")
.label("AIRTIME")
.build(),
Category.builder()
.name("Utilities")
.description("Council bills & rates")
.label("UTILITIES")
.build()
);
for (Category category : categories) {
if (categoryRepository.existsByName(category.getName())) {
log.info("Category '{}' already exists, skipping.", category.getName());
} else {
categoryRepository.save(category);
log.info("Category '{}' seeded.", category.getName());
}
}
}
}

View File

@@ -0,0 +1,54 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.ChargeConditionType;
import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.repositories.ChargeConditionRepository;
import java.util.Arrays;
import java.util.List;
/**
* Seeds ChargeCondition entities.
* Priority: 5th - Must run after ProviderSeeder and before ChargeSeeder.
* Only seeds entities that don't already exist (checked by codes).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChargeConditionSeeder implements Seeder {
private final ChargeConditionRepository chargeConditionRepository;
@Override
public void seed() {
List<ChargeCondition> chargeConditions = Arrays.asList(
ChargeCondition.builder()
.name("Ecocash Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("ECOCASH")
.build(),
ChargeCondition.builder()
.name("Steward Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("STEWARD")
.build(),
ChargeCondition.builder()
.name("Mastercard Gateway")
.type(ChargeConditionType.GATEWAY)
.codes("MPGS")
.build()
);
for (ChargeCondition condition : chargeConditions) {
if (chargeConditionRepository.existsByCodes(condition.getCodes())) {
log.info("ChargeCondition '{}' already exists, skipping.", condition.getCodes());
} else {
chargeConditionRepository.save(condition);
log.info("ChargeCondition '{}' seeded.", condition.getCodes());
}
}
}
}

View File

@@ -0,0 +1,72 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.enums.ChargeLabelEnum;
import zw.qantra.tm.domain.enums.CurrencyType;
import zw.qantra.tm.domain.models.Charge;
import zw.qantra.tm.domain.models.ChargeCondition;
import zw.qantra.tm.domain.repositories.ChargeConditionRepository;
import zw.qantra.tm.domain.repositories.ChargeRepository;
import java.math.BigDecimal;
import java.util.Arrays;
import java.util.HashSet;
/**
* Seeds Charge entities.
* Priority: 6th - Must run last, after ChargeConditionSeeder.
* Only seeds entities that don't already exist (checked by chargeLabel, currency, and percentageRate).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ChargeSeeder implements Seeder {
private final ChargeRepository chargeRepository;
private final ChargeConditionRepository chargeConditionRepository;
@Override
public void seed() {
// Look up persisted ChargeConditions by their codes
ChargeCondition ecocashCondition = chargeConditionRepository.findByCodes("ECOCASH").orElse(null);
ChargeCondition mpgsCondition = chargeConditionRepository.findByCodes("MPGS").orElse(null);
if (ecocashCondition == null || mpgsCondition == null) {
log.warn("Required ChargeConditions not found. Skipping Charge seeding.");
return;
}
// Ecocash Gateway Fee (1%)
if (chargeRepository.existsByChargeLabelAndCurrencyAndPercentageRate(
ChargeLabelEnum.GATEWAY_FEE, CurrencyType.USD, new BigDecimal("1"))) {
log.info("Charge 'Gateway fee (1%)' already exists, skipping.");
} else {
chargeRepository.save(Charge.builder()
.description("Gateway fee")
.chargeLabel(ChargeLabelEnum.GATEWAY_FEE)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("1"))
.includes(new HashSet<>(Arrays.asList(ecocashCondition)))
.build());
log.info("Charge 'Gateway fee (1%)' seeded.");
}
// MPGS Gateway Fee (2%, min 0.55)
if (chargeRepository.existsByChargeLabelAndCurrencyAndPercentageRate(
ChargeLabelEnum.GATEWAY_FEE, CurrencyType.USD, new BigDecimal("2"))) {
log.info("Charge 'Gateway fee (2%)' already exists, skipping.");
} else {
chargeRepository.save(Charge.builder()
.description("Gateway fee")
.chargeLabel(ChargeLabelEnum.GATEWAY_FEE)
.currency(CurrencyType.USD)
.percentageRate(new BigDecimal("1"))
.min(new BigDecimal("0.20"))
.includes(new HashSet<>(Arrays.asList(mpgsCondition)))
.build());
log.info("Charge 'Gateway fee (2%)' seeded.");
}
}
}

View File

@@ -0,0 +1,48 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.IntegrationProcessor;
import zw.qantra.tm.domain.repositories.IntegrationProcessorRepository;
import java.util.Arrays;
import java.util.List;
/**
* Seeds IntegrationProcessor entities.
* Priority: 2nd - Must run after PaymentProcessorSeeder.
* Only seeds entities that don't already exist (checked by name).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class IntegrationProcessorSeeder implements Seeder {
private final IntegrationProcessorRepository integrationProcessorRepository;
@Override
public void seed() {
List<IntegrationProcessor> processors = Arrays.asList(
IntegrationProcessor.builder()
.name("HOT")
.label("HOT")
.description("Hot Recharge Integration Processor")
.build(),
IntegrationProcessor.builder()
.name("STEWARD")
.label("STEWARD")
.description("Steward Integration Processor")
.build()
);
for (IntegrationProcessor processor : processors) {
if (integrationProcessorRepository.existsByName(processor.getName())) {
log.info("IntegrationProcessor '{}' already exists, skipping.", processor.getName());
} else {
integrationProcessorRepository.save(processor);
log.info("IntegrationProcessor '{}' seeded.", processor.getName());
}
}
}
}

View File

@@ -0,0 +1,65 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
/**
* Orchestrates all seeders in the correct priority order matching
* ConfigController.seedLiveConfig() execution sequence:
* <p>
* 1. PaymentProcessorSeeder
* 2. IntegrationProcessorSeeder
* 3. CategorySeeder
* 4. ProviderSeeder
* 5. ChargeConditionSeeder
* 6. ChargeSeeder
* 7. SettingSeeder
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class LiveConfigSeeder {
private final PaymentProcessorSeeder paymentProcessorSeeder;
private final IntegrationProcessorSeeder integrationProcessorSeeder;
private final CategorySeeder categorySeeder;
private final ProviderSeeder providerSeeder;
private final ChargeConditionSeeder chargeConditionSeeder;
private final ChargeSeeder chargeSeeder;
private final SettingSeeder settingSeeder;
/**
* Runs all seeders in the correct priority order.
* This method is idempotent and can be called multiple times.
*/
@PostConstruct
public void seedAll() {
log.info("Starting live configuration seeding...");
log.info("Seeding Payment Processors...");
paymentProcessorSeeder.seed();
log.info("Seeding Integration Processors...");
integrationProcessorSeeder.seed();
log.info("Seeding Categories...");
categorySeeder.seed();
log.info("Seeding Providers...");
providerSeeder.seed();
log.info("Seeding Charge Conditions...");
chargeConditionSeeder.seed();
log.info("Seeding Charges...");
chargeSeeder.seed();
log.info("Seeding Settings...");
settingSeeder.seed();
log.info("Live configuration seeding complete.");
}
}

View File

@@ -0,0 +1,58 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.PaymentProcessor;
import zw.qantra.tm.domain.repositories.PaymentProcessorRepository;
import java.util.Arrays;
import java.util.List;
/**
* Seeds PaymentProcessor entities.
* Priority: 1st - Must run before any other seeder.
* Only seeds entities that don't already exist (checked by label).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PaymentProcessorSeeder implements Seeder {
private final PaymentProcessorRepository paymentProcessorRepository;
@Override
public void seed() {
List<PaymentProcessor> processors = Arrays.asList(
PaymentProcessor.builder()
.name("Ecocash")
.label("ECOCASH")
.type("GATEWAY")
.image("ecocash.png")
.accountFieldName("phoneNumber")
.accountFieldLabel("EcoCash Phone Number")
.description("Pay using your mobile wallet")
.authType("REMOTE")
.build(),
PaymentProcessor.builder()
.name("Visa/MasterCard")
.label("MPGS")
.type("GATEWAY")
.image("visa-mastercard.png")
.accountFieldName("cardNumber")
.accountFieldLabel("Card Number")
.description("Pay using your international card")
.authType("WEB")
.build()
);
for (PaymentProcessor processor : processors) {
if (paymentProcessorRepository.existsByLabel(processor.getLabel())) {
log.info("PaymentProcessor '{}' already exists, skipping.", processor.getLabel());
} else {
paymentProcessorRepository.save(processor);
log.info("PaymentProcessor '{}' seeded.", processor.getLabel());
}
}
}
}

View File

@@ -0,0 +1,103 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.Provider;
import zw.qantra.tm.domain.repositories.ProviderRepository;
import zw.qantra.tm.utils.Utils;
import java.util.Arrays;
import java.util.List;
/**
* Seeds Provider entities.
* Priority: 4th - Must run after CategorySeeder (providers reference categories).
* Only seeds entities that don't already exist (checked by clientId).
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ProviderSeeder implements Seeder {
private final ProviderRepository providerRepository;
@Override
public void seed() {
List<Provider> providers = Arrays.asList(
Provider.builder()
.name("Econet Airtime")
.description("Econet Airtime")
.clientId("econet_airtime")
.label("ECONET")
.category("AIRTIME")
.image("econet.png")
.externalId("78f1f497-c9eb-401c-b22c-884756e68e40")
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAmount(true)
.requiresPhone(true)
.uid(Utils.getUid())
.build(),
Provider.builder()
.name("NetOne Airtime")
.description("NetOne Airtime")
.clientId("netone_usd_airtime")
.label("NETONE")
.category("AIRTIME")
.image("netone.png")
.externalId("95d485a7-39c9-4559-8a27-6521969abe8f")
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAmount(true)
.requiresPhone(true)
.uid(Utils.getUid())
.build(),
Provider.builder()
.name("Zesa Prepaid")
.description("Prepaid Zesa")
.clientId("zesa_prepaid_usd")
.label("ZESA")
.category("UTILITIES")
.image("zesa.png")
.externalId("0f67f7cc-b62b-4fb5-915f-6740b2afdba6")
.integrationProcessorLabel("HOT")
.accountFieldName("Meter Number")
.percentageCommission(1.5)
.requiresAccount(true)
.requiresAmount(true)
.requiresPhone(true)
.uid(Utils.getUid())
.build(),
Provider.builder()
.name("Econet Bundles")
.description("Econet Bundles")
.clientId("econet_bundles_usd")
.label("ECONET_BUNDLES")
.category("AIRTIME")
.image("econet.png")
.externalId("287863e2-a791-4106-b629-79dadc9a6779")
.integrationProcessorLabel("HOT")
.accountFieldName("Phone Number")
.percentageCommission(5)
.requiresAccount(true)
.requiresAmount(false)
.requiresPhone(true)
.uid(Utils.getUid())
.build()
);
for (Provider provider : providers) {
if (providerRepository.existsByClientId(provider.getClientId())) {
log.info("Provider '{}' already exists, skipping.", provider.getClientId());
} else {
providerRepository.save(provider);
log.info("Provider '{}' seeded.", provider.getClientId());
}
}
}
}

View File

@@ -0,0 +1,10 @@
package zw.qantra.tm.seeders;
/**
* Base interface for all seeders.
* Each seeder encapsulates the seeding logic for a specific entity
* following the order defined in the original seedLiveConfig method.
*/
public interface Seeder {
void seed();
}

View File

@@ -0,0 +1,61 @@
package zw.qantra.tm.seeders;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import zw.qantra.tm.domain.models.Setting;
import zw.qantra.tm.domain.repositories.SettingRepository;
import java.util.Arrays;
import java.util.List;
/**
* Seeds Setting entities for simulation flags.
* Uses findBySettingName to check for existing records before seeding.
* When the "lab" Spring profile is active, simulation flags default to "true".
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SettingSeeder implements Seeder {
private final SettingRepository settingRepository;
private final Environment environment;
@Override
public void seed() {
boolean isLab = environment.matchesProfiles("lab");
String defaultValue = isLab ? "true" : "false";
log.info("Lab profile active: {}. Setting simulation defaults to '{}'.", isLab, defaultValue);
List<Setting> settings = Arrays.asList(
Setting.builder()
.settingName("SIMULATE_MPGS_SUCCESS")
.settingValue(defaultValue)
.build(),
Setting.builder()
.settingName("SIMULATE_ECOCASH_SUCCESS")
.settingValue(defaultValue)
.build(),
Setting.builder()
.settingName("SIMULATE_HOT_SUCCESS")
.settingValue(defaultValue)
.build(),
Setting.builder()
.settingName("VELOCITY_COMPANY_ID")
.settingValue("d5d381af-f632-464b-a97b-048a8b17f99d")
.build()
);
for (Setting setting : settings) {
if (settingRepository.findBySettingName(setting.getSettingName()) != null) {
log.info("Setting '{}' already exists, skipping.", setting.getSettingName());
} else {
settingRepository.save(setting);
log.info("Setting '{}' seeded with value '{}'.", setting.getSettingName(), setting.getSettingValue());
}
}
}
}

View File

@@ -2,6 +2,7 @@ package zw.qantra.tm.utils;
import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.util.StdDateFormat; import com.fasterxml.jackson.databind.util.StdDateFormat;
@@ -47,6 +48,7 @@ public class Utils {
public static <T> T fromJson(String json, Class<T> clazz){ public static <T> T fromJson(String json, Class<T> clazz){
ObjectMapper mapper = new ObjectMapper(); ObjectMapper mapper = new ObjectMapper();
mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
mapper.setDateFormat(new StdDateFormat().withColonInTimeZone(true)); mapper.setDateFormat(new StdDateFormat().withColonInTimeZone(true));
mapper.registerModule(new JavaTimeModule()); mapper.registerModule(new JavaTimeModule());
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);

View File

@@ -10,9 +10,9 @@ server.servlet.context-path=/api
spring.jpa.hibernate.ddl-auto=update spring.jpa.hibernate.ddl-auto=update
spring.datasource.driver-class-name=org.postgresql.Driver spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://173.212.247.232:5532/qpay?useLegacyDatetimeCode=false spring.datasource.url=jdbc:postgresql://localhost:5432/qpay?useLegacyDatetimeCode=false
spring.datasource.username=postgres spring.datasource.username=postgres
spring.datasource.password=zdDZMzq6F4B4L1IUl spring.datasource.password=example
# sbz.merchant.url=http://192.168.16.29:24050/v1 # sbz.merchant.url=http://192.168.16.29:24050/v1
@@ -28,29 +28,19 @@ sbz.aggregator.encryption-key=735470ce-8efb-4c0f-ba3f-7298f805ab33
steward.payment.processor.url=${sbz.merchant.url} steward.payment.processor.url=${sbz.merchant.url}
# ERPNext Configuration
erpnext.url=http://173.212.247.232:8085/api
erpnext.api.key=1102c6da0312f75
erpnext.api.secret=e3d95692bcf16c7
erpnext.username=Administrator
erpnext.password=admin
erpnext.company=Qantra
erpnext.accounts.cash=Bank Account - Q
erpnext.accounts.gatewayexpense=Gateway Expenses - Q
erpnext.accounts.accountsreceivable=Debtors - Q
erpnext.accounts.commission=Discounts Received - Q
erpnext.accounts.payable=Creditors - Q
erpnext.income.account=Sales Income - Q
erpnext.cost.center=Main - Q
erpnext.tax.account=VAT - Q
erpnext.tax.rate=15.0
logging.level.root=WARN logging.level.root=WARN
spring.datasource.nflow.url=jdbc:postgresql://173.212.247.232:5532/nflow spring.datasource.nflow.url=jdbc:postgresql://localhost:5432/qpay-nflow
spring.datasource.nflow.username=postgres spring.datasource.nflow.username=postgres
spring.datasource.nflow.password=zdDZMzq6F4B4L1IUl spring.datasource.nflow.password=example
powertel.clientid=powertel_zesa powertel.clientid=powertel_zesa
ecocash.apikey=OUzGezwTgktLg_9v0I48I6WUM4LVLGFl ecocash.apikey=OUzGezwTgktLg_9v0I48I6WUM4LVLGFl
ecocash.url=https://developers.ecocash.co.zw/api/ecocash_pay/api/v2/payment/instant/c2b/sandbox ecocash.url=https://developers.ecocash.co.zw/api/ecocash_pay/api/v2/payment/instant/c2b/sandbox
velocity.api.base-url=https://api.velocityafrica.net
velocity.api.api-key=z5WdSlItIhYL6nd7rxzf-jFdKN3jJyL1LubhDFBvXmc
#velocity.api.base-url=http://localhost:9005
#velocity.api.api-key=gH13nirZdUQdqC9impgzTzqEqv-jdbLOTx_HD5fQr_A
velocity.api.cancel-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll

View File

@@ -0,0 +1,42 @@
spring.application.name=tm
debug=false
logging.level.org.springframework.boot.autoconfigure=INFO
server.port=6950
server.servlet.context-path=/api
spring.jpa.hibernate.ddl-auto=update
spring.datasource.driver-class-name=org.postgresql.Driver
spring.datasource.url=jdbc:postgresql://173.212.247.232:5532/qpay?useLegacyDatetimeCode=false
spring.datasource.username=postgres
spring.datasource.password=zdDZMzq6F4B4L1IUl
# sbz.merchant.url=http://192.168.16.29:24050/v1
# sbz.merchant.url=http://localhost:24000/v1
sbz.merchant.url=https://api.stewardpay.co.zw/lab/v2
sbz.aggregator.url=${sbz.merchant.url}
sbz.aggregator.phone=263773591219
sbz.aggregator.account=1046737845
sbz.aggregator.client-id=steward_pay
sbz.aggregator.client-secret=XjkbBAJeJCnAplYDTbp5xOjeyySVnYGp
sbz.aggregator.encryption-key=735470ce-8efb-4c0f-ba3f-7298f805ab33
steward.payment.processor.url=${sbz.merchant.url}
logging.level.root=WARN
spring.datasource.nflow.url=jdbc:postgresql://173.212.247.232:5532/qpay-nflow
spring.datasource.nflow.username=postgres
spring.datasource.nflow.password=zdDZMzq6F4B4L1IUl
powertel.clientid=powertel_zesa
ecocash.apikey=OUzGezwTgktLg_9v0I48I6WUM4LVLGFl
ecocash.url=https://developers.ecocash.co.zw/api/ecocash_pay/api/v2/payment/instant/c2b/sandbox
velocity.api.cancel-url=http://localhost:9005/poll
velocity.api.success-url=http://localhost:9005/poll

View File

@@ -30,23 +30,6 @@ steward.payment.processor.url=${sbz.merchant.url}
mpgs.return-url=https://vdt.co.zw/#/return mpgs.return-url=https://vdt.co.zw/#/return
# ERPNext Configuration
erpnext.url=http://62.171.179.108:8080/api
erpnext.api.key=dff94c65b52ecbd
erpnext.api.secret=49218fe805c9fb4
erpnext.username=Administrator
erpnext.password=admin
erpnext.company=QantraTech
erpnext.accounts.cash=Steward Bank Account - Q
erpnext.accounts.gatewayexpense=Gateway Expenses - Q
erpnext.accounts.accountsreceivable=Debtors - Q
erpnext.accounts.commission=Discounts Received - Q
erpnext.accounts.payable=Creditors - Q
erpnext.income.account=Sales Income - Q
erpnext.cost.center=Main - Q
erpnext.tax.account=VAT - Q
erpnext.tax.rate=15.0
# JWT Configuration # JWT Configuration
jwt.secret=your-super-secret-jwt-key-here-make-it-very-long-and-secure-in-production jwt.secret=your-super-secret-jwt-key-here-make-it-very-long-and-secure-in-production
jwt.expiration=86400000 jwt.expiration=86400000
@@ -57,10 +40,6 @@ spring.datasource.nflow.username=postgres
spring.datasource.nflow.password=zdDZMzq6F4B4L1IUla spring.datasource.nflow.password=zdDZMzq6F4B4L1IUla
spring.main.allow-bean-definition-overriding=true spring.main.allow-bean-definition-overriding=true
# Email Configuration
infobip.url=https://z3m696.api.infobip.com/sms/2/text/advanced
infobip.apikey=0ede918b7c1110465a0518021521638c-ebc4fdb7-547d-413b-bbca-781949ac6597
spring.mail.host=mail.zimscape.com spring.mail.host=mail.zimscape.com
spring.mail.port=587 spring.mail.port=587
spring.mail.username=notifications@qantra.co.zw spring.mail.username=notifications@qantra.co.zw
@@ -85,4 +64,9 @@ hot.api.url=https://ssl.hot.co.zw/api/v3
hot.auth.url=https://ssl.hot.co.zw/api/v3/identity/login hot.auth.url=https://ssl.hot.co.zw/api/v3/identity/login
hot.access.code=vusumuzi@qantra.co.zw hot.access.code=vusumuzi@qantra.co.zw
hot.password=Stikbanch@50 hot.password=Stikbanch@50
hot.token.expiry.minutes=30 hot.token.expiry.minutes=25
velocity.api.base-url=https://api.velocityafrica.net
velocity.api.api-key=z5WdSlItIhYL6nd7rxzf-jFdKN3jJyL1LubhDFBvXmc
velocity.api.cancel-url=https://pay.velocityafrica/poll
velocity.api.success-url=https://pay.velocityafrica/poll

View File

@@ -1,5 +1,5 @@
changeLogFile=src/main/resources/liquibase-diff-changeLog.xml changeLogFile=src/main/resources/liquibase-diff-changeLog.xml
url=jdbc:postgresql://173.212.247.232:5532/qpay?serverTimezone=Africa/Harare&useLegacyDatetimeCode=false url=jdbc:postgresql://localhost:5432/qpay?serverTimezone=Africa/Harare&useLegacyDatetimeCode=false
username=postgres username=postgres
password=zdDZMzq6F4B4L1IUl password=zdDZMzq6F4B4L1IUl