Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae69d126a9 | ||
|
|
e4a2c00d75 | ||
|
|
a8f8aa39b7 |
+5
-1
@@ -21,7 +21,11 @@ package org.apache.fineract.portfolio.aml.domain;
|
|||||||
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
||||||
public enum AmlAppliesTo {
|
public enum AmlAppliesTo {
|
||||||
|
|
||||||
SAVINGS, LOAN, TRANSFER, ALL;
|
/**
|
||||||
|
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
|
||||||
|
* for it is catalogued but does not fire until it is.
|
||||||
|
*/
|
||||||
|
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
|
||||||
|
|
||||||
public boolean matches(AmlTransactionType transactionType) {
|
public boolean matches(AmlTransactionType transactionType) {
|
||||||
return this == ALL || this.name().equals(transactionType.name());
|
return this == ALL || this.name().equals(transactionType.name());
|
||||||
|
|||||||
+9
@@ -152,6 +152,15 @@ public class AmlTransactionRule {
|
|||||||
return rule;
|
return rule;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
|
||||||
|
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
|
||||||
|
*/
|
||||||
|
public void restrictTo(final String direction, final boolean cashOnly) {
|
||||||
|
this.direction = direction;
|
||||||
|
this.cashOnly = cashOnly;
|
||||||
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinColumn;
|
|||||||
import jakarta.persistence.ManyToOne;
|
import jakarta.persistence.ManyToOne;
|
||||||
import jakarta.persistence.Table;
|
import jakarta.persistence.Table;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDate;
|
||||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -118,6 +119,16 @@ public class ClientRiskRule {
|
|||||||
@Column(name = "cc_relevant", nullable = false)
|
@Column(name = "cc_relevant", nullable = false)
|
||||||
private boolean ccRelevant;
|
private boolean ccRelevant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
|
||||||
|
* normal. Every rule that was already in the catalog is normal.
|
||||||
|
*/
|
||||||
|
@Column(name = "on_trial", nullable = false)
|
||||||
|
private boolean onTrial;
|
||||||
|
|
||||||
|
@Column(name = "trial_started_on")
|
||||||
|
private LocalDate trialStartedOn;
|
||||||
|
|
||||||
public static final String KIND_KYC = "KYC";
|
public static final String KIND_KYC = "KYC";
|
||||||
public static final String KIND_AML = "AML";
|
public static final String KIND_AML = "AML";
|
||||||
|
|
||||||
@@ -127,6 +138,30 @@ public class ClientRiskRule {
|
|||||||
return ccRelevant;
|
return ccRelevant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isOnTrial() {
|
||||||
|
return onTrial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public LocalDate getTrialStartedOn() {
|
||||||
|
return trialStartedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a newly authored rule on trial from {@code startedOn}. */
|
||||||
|
public void startTrial(final LocalDate startedOn) {
|
||||||
|
this.onTrial = true;
|
||||||
|
this.trialStartedOn = startedOn;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ends the trial: the rule is a normal part of the catalog. */
|
||||||
|
public void markNormal() {
|
||||||
|
this.onTrial = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
|
||||||
|
public void markCcRelevant(final boolean ccRelevant) {
|
||||||
|
this.ccRelevant = ccRelevant;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||||
|
|||||||
+4
@@ -42,4 +42,8 @@ public interface CurrentAccountReadPlatformService {
|
|||||||
|
|
||||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
||||||
|
|
||||||
|
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
|
||||||
|
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||||
|
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,8 +81,6 @@ dependencies {
|
|||||||
'org.apache.avro:avro',
|
'org.apache.avro:avro',
|
||||||
'jakarta.ws.rs:jakarta.ws.rs-api',
|
'jakarta.ws.rs:jakarta.ws.rs-api',
|
||||||
'org.glassfish.jersey.media:jersey-media-multipart',
|
'org.glassfish.jersey.media:jersey-media-multipart',
|
||||||
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
|
|
||||||
'org.glassfish.jersey.media:jersey-media-sse',
|
|
||||||
|
|
||||||
'org.springframework:spring-jms',
|
'org.springframework:spring-jms',
|
||||||
'org.springframework:spring-context-support',
|
'org.springframework:spring-context-support',
|
||||||
|
|||||||
-67
@@ -1570,76 +1570,9 @@ public class AccountingProcessorHelper {
|
|||||||
return mapping == null ? null : mapping.getGlAccount();
|
return mapping == null ? null : mapping.getGlAccount();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
|
|
||||||
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
|
|
||||||
*/
|
|
||||||
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
|
|
||||||
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
|
|
||||||
* than the organisation-wide Liability Transfer financial activity.
|
|
||||||
* <p>
|
|
||||||
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
|
|
||||||
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
|
|
||||||
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
|
|
||||||
* Transfer as before.
|
|
||||||
* <p>
|
|
||||||
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
|
|
||||||
*
|
|
||||||
* @return true when a product mapping was found and is now in use
|
|
||||||
*/
|
|
||||||
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
|
|
||||||
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
|
|
||||||
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
|
|
||||||
if (account == null) {
|
|
||||||
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
|
|
||||||
}
|
|
||||||
if (account == null) {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.set(account);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void clearProductTransferSuspense() {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
|
|
||||||
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
|
|
||||||
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
|
|
||||||
* suspense account would be reversed against Liability Transfer and leave both accounts open.
|
|
||||||
* <p>
|
|
||||||
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
|
|
||||||
* it). Nothing is set where the leg has no journal entries. Must be paired with
|
|
||||||
* {@link #clearProductTransferSuspense()} in a finally block.
|
|
||||||
*/
|
|
||||||
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
|
|
||||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
|
||||||
if (savingsTransactionId == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final List<Long> found = jdbcTemplate.queryForList("""
|
|
||||||
SELECT account_id FROM acc_gl_journal_entry
|
|
||||||
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
|
|
||||||
ORDER BY amount DESC, id
|
|
||||||
LIMIT 1
|
|
||||||
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
|
|
||||||
if (!found.isEmpty()) {
|
|
||||||
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||||
final Long paymentTypeId) {
|
final Long paymentTypeId) {
|
||||||
GLAccount glAccount;
|
GLAccount glAccount;
|
||||||
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
|
|
||||||
return TRANSFER_CLEARING_ACCOUNT.get();
|
|
||||||
}
|
|
||||||
if (isOrganizationAccount(accountMappingTypeId)) {
|
if (isOrganizationAccount(accountMappingTypeId)) {
|
||||||
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
||||||
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
||||||
|
|||||||
+1
-1
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
|
|||||||
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
||||||
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
||||||
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
||||||
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
|
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
||||||
|
|||||||
+15
-45
@@ -144,22 +144,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
|
|
||||||
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
||||||
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
||||||
|
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
|
||||||
|
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
||||||
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
||||||
|
|
||||||
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
|
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||||
final SavingsAccountTransaction withdrawal;
|
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
final SavingsAccountTransaction deposit;
|
|
||||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
|
||||||
try {
|
|
||||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
|
|
||||||
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
|
||||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
|
|
||||||
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||||
@@ -285,27 +277,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
if (accountTransfer.getToLoanTransaction() != null) {
|
if (accountTransfer.getToLoanTransaction() != null) {
|
||||||
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
||||||
}
|
}
|
||||||
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
|
|
||||||
// Liability Transfer), so the reversal clears that same account.
|
|
||||||
if (accountTransfer.getFromTransaction() != null) {
|
if (accountTransfer.getFromTransaction() != null) {
|
||||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
|
this.savingsAccountWritePlatformService.undoTransaction(
|
||||||
try {
|
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(
|
true);
|
||||||
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
|
|
||||||
accountTransfer.getFromTransaction().getId(), true);
|
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (accountTransfer.getToSavingsTransaction() != null) {
|
if (accountTransfer.getToSavingsTransaction() != null) {
|
||||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
|
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
||||||
try {
|
accountTransfer.getToSavingsTransaction().getId(), true);
|
||||||
this.savingsAccountWritePlatformService.undoTransaction(
|
|
||||||
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
|
||||||
accountTransfer.getToSavingsTransaction().getId(), true);
|
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
accountTransfer.reverse();
|
accountTransfer.reverse();
|
||||||
this.accountTransferRepository.save(accountTransfer);
|
this.accountTransferRepository.save(accountTransfer);
|
||||||
@@ -428,22 +407,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
|||||||
transactionDate = transactionDate.plusDays(1);
|
transactionDate = transactionDate.plusDays(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
|
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
|
||||||
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
|
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||||
final SavingsAccountTransaction withdrawal;
|
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||||
final SavingsAccountTransaction deposit;
|
|
||||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
|
||||||
try {
|
|
||||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
|
|
||||||
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
|
||||||
transactionBooleanValues, backdatedTxnsAllowedTill);
|
|
||||||
|
|
||||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
|
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
|
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||||
isRegularTransaction, backdatedTxnsAllowedTill);
|
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||||
} finally {
|
|
||||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
|
||||||
}
|
|
||||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||||
|
|
||||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||||
|
|||||||
+882
-13
@@ -22,17 +22,24 @@ import com.google.gson.Gson;
|
|||||||
import com.google.gson.GsonBuilder;
|
import com.google.gson.GsonBuilder;
|
||||||
import jakarta.ws.rs.DefaultValue;
|
import jakarta.ws.rs.DefaultValue;
|
||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.NotFoundException;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
import jakarta.ws.rs.QueryParam;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.math.RoundingMode;
|
import java.math.RoundingMode;
|
||||||
|
import java.time.DayOfWeek;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
|
import java.time.temporal.TemporalAdjusters;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
@@ -41,13 +48,19 @@ import org.springframework.stereotype.Component;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
|
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
|
||||||
* and what the bank sanctioned.
|
* and what the bank sanctioned - and, for one account, where it came from and went, by what mode, what that adds up to,
|
||||||
|
* and which of the bank's CC rules it hits and which it does not.
|
||||||
*
|
*
|
||||||
* <p>
|
* <p>
|
||||||
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
|
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
|
||||||
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account
|
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; only those are listed. Accounts are
|
||||||
* is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's
|
* limited to the caller's branch hierarchy, and the busiest are listed first.
|
||||||
* branch hierarchy, and the busiest are listed first.
|
*
|
||||||
|
* <p>
|
||||||
|
* The rule checks apply each CC rule's own logic to the account's own transactions here, the way the engine would if it were
|
||||||
|
* fed them. A rule that turns on an officer's judgement, or that the system does not yet check, is shown as not checked
|
||||||
|
* rather than as passed. The observations and the fixed values behind the CC scenarios are indicative starting values, not
|
||||||
|
* findings; the bank's compliance team sets the real thresholds.
|
||||||
*/
|
*/
|
||||||
@Path("/v1/aml/cc-tracking")
|
@Path("/v1/aml/cc-tracking")
|
||||||
@Component
|
@Component
|
||||||
@@ -55,6 +68,30 @@ import org.springframework.stereotype.Component;
|
|||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public class AmlCcTrackingApiResource {
|
public class AmlCcTrackingApiResource {
|
||||||
|
|
||||||
|
// ── Starting values for the observations and the CC scenarios (indicative; to be set by the bank) ─────────────────
|
||||||
|
/** Credits and debits this many times the declared turnover (or the sanctioned limit) are flagged. */
|
||||||
|
private static final BigDecimal TURNOVER_MULTIPLE = BigDecimal.valueOf(3);
|
||||||
|
/** A credit is "rapid in-out" when this share of it is paid out again within {@link #RAPID_DAYS} days. */
|
||||||
|
private static final BigDecimal RAPID_SHARE = new BigDecimal("0.80");
|
||||||
|
private static final int RAPID_DAYS = 3;
|
||||||
|
/** One party handling this share of all the money moved is a concentration. */
|
||||||
|
private static final BigDecimal CONCENTRATION_SHARE = new BigDecimal("0.50");
|
||||||
|
/** Cash making up this share of credits or debits is a high cash share. */
|
||||||
|
private static final BigDecimal CASH_SHARE = new BigDecimal("0.50");
|
||||||
|
/** Peak use of the sanctioned limit worth noting, and the days within which a return to credit counts as "cleared". */
|
||||||
|
private static final BigDecimal LIMIT_USE = new BigDecimal("0.80");
|
||||||
|
private static final int CLEARED_DAYS = 7;
|
||||||
|
/** A party both paying in and being paid, the smaller side at least this share of the larger. */
|
||||||
|
private static final BigDecimal TWO_WAY_SHARE = new BigDecimal("0.50");
|
||||||
|
/** Money with no counterparty recorded, as a share of everything moved. */
|
||||||
|
private static final BigDecimal UNRECORDED_SHARE = new BigDecimal("0.50");
|
||||||
|
/** The window's turnover this many times the account's own earlier average is a spike. */
|
||||||
|
private static final BigDecimal SPIKE_MULTIPLE = BigDecimal.valueOf(3);
|
||||||
|
|
||||||
|
private static final String HIT = "HIT";
|
||||||
|
private static final String NO_HIT = "NO_HIT";
|
||||||
|
private static final String NOT_CHECKED = "NOT_CHECKED";
|
||||||
|
|
||||||
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
|
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
|
||||||
private static final String SQL = """
|
private static final String SQL = """
|
||||||
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
|
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
|
||||||
@@ -75,16 +112,40 @@ public class AmlCcTrackingApiResource {
|
|||||||
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
|
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||||
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||||
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||||
WHERE ca.status_enum = 300
|
WHERE ca.status_enum = 300 AND ca.allow_overdraft = true
|
||||||
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
||||||
GROUP BY ca.id, c.id, o.id, k.id
|
GROUP BY ca.id, c.id, o.id, k.id
|
||||||
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
|
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
|
||||||
""";
|
""";
|
||||||
|
|
||||||
|
private static final String TRAIL_SELECT = """
|
||||||
|
SELECT t.id, t.current_account_id, t.transaction_date, t.transaction_type_enum, t.amount, t.ref_no, t.remarks,
|
||||||
|
t.running_balance_derived, pt.value AS payment_type, COALESCE(pt.is_cash_payment, false) AS is_cash,
|
||||||
|
pd.account_number AS cp_account, pd.bank_number AS cp_bank, pd.routing_code AS cp_ifsc, pd.check_number AS cheque_no
|
||||||
|
FROM m_current_account_transaction t
|
||||||
|
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||||
|
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||||
|
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
|
||||||
|
AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||||
|
""";
|
||||||
|
|
||||||
|
/** The CC rules: every AML scenario marked as watching a CC account, with the live-transaction rule behind it, if any. */
|
||||||
|
private static final String RULES_SQL = """
|
||||||
|
SELECT r.id, r.rule_name, r.risk_category, r.coverage_status, r.on_trial,
|
||||||
|
t.rule_type, t.threshold_amount, t.min_limit_amount, t.max_limit_amount, t.window_days, t.direction,
|
||||||
|
t.cash_only, t.min_transaction_count, t.subject_scope, t.is_active
|
||||||
|
FROM m_client_risk_rule r
|
||||||
|
LEFT JOIN m_aml_transaction_rule t ON t.source_client_risk_rule_id = r.id
|
||||||
|
WHERE r.cc_relevant = true AND r.rule_kind = 'AML'
|
||||||
|
ORDER BY r.on_trial DESC, r.id
|
||||||
|
""";
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final JdbcTemplate jdbcTemplate;
|
private final JdbcTemplate jdbcTemplate;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
// ── The list ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
|
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||||
@@ -92,22 +153,28 @@ public class AmlCcTrackingApiResource {
|
|||||||
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
||||||
final LocalDate from = asOf.minusDays(window);
|
final LocalDate from = asOf.minusDays(window);
|
||||||
|
|
||||||
|
final List<Map<String, Object>> accountRows = this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
|
||||||
|
java.sql.Date.valueOf(asOf), hierarchy + "%");
|
||||||
|
|
||||||
|
// The transactions and earlier history of all the accounts at once, to check each against the CC rules
|
||||||
|
final Map<Long, List<Txn>> txnsByAccount = loadTxns(from, asOf, null);
|
||||||
|
final Map<Long, BigDecimal> priorVolume = priorVolumes(from, window);
|
||||||
|
final List<CcRule> rules = loadRules();
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
int rank = 0;
|
int rank = 0;
|
||||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
|
for (final Map<String, Object> r : accountRows) {
|
||||||
java.sql.Date.valueOf(asOf), hierarchy + "%")) {
|
final long accountId = ((Number) r.get("account_id")).longValue();
|
||||||
final BigDecimal credits = money(r.get("credits"));
|
final BigDecimal credits = money(r.get("credits"));
|
||||||
final BigDecimal debits = money(r.get("debits"));
|
final BigDecimal debits = money(r.get("debits"));
|
||||||
final BigDecimal limit = money(r.get("overdraft_limit"));
|
final BigDecimal limit = money(r.get("overdraft_limit"));
|
||||||
final BigDecimal balance = money(r.get("account_balance_derived"));
|
final BigDecimal balance = money(r.get("account_balance_derived"));
|
||||||
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
|
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
|
||||||
// What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month.
|
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window);
|
||||||
final BigDecimal declaredForWindow = declaredMonthly == null ? null
|
|
||||||
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
|
|
||||||
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
row.put("rank", ++rank);
|
row.put("rank", ++rank);
|
||||||
row.put("accountId", r.get("account_id"));
|
row.put("accountId", accountId);
|
||||||
row.put("accountNo", r.get("account_no"));
|
row.put("accountNo", r.get("account_no"));
|
||||||
row.put("clientId", r.get("client_id"));
|
row.put("clientId", r.get("client_id"));
|
||||||
row.put("clientName", r.get("client_name"));
|
row.put("clientName", r.get("client_name"));
|
||||||
@@ -115,7 +182,6 @@ public class AmlCcTrackingApiResource {
|
|||||||
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
|
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
|
||||||
row.put("sanctionedLimit", limit);
|
row.put("sanctionedLimit", limit);
|
||||||
row.put("balance", balance);
|
row.put("balance", balance);
|
||||||
// How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit.
|
|
||||||
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
|
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
|
||||||
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
||||||
row.put("declaredMonthlyTurnover", declaredMonthly);
|
row.put("declaredMonthlyTurnover", declaredMonthly);
|
||||||
@@ -128,10 +194,28 @@ public class AmlCcTrackingApiResource {
|
|||||||
row.put("cashDebits", money(r.get("cash_debits")));
|
row.put("cashDebits", money(r.get("cash_debits")));
|
||||||
row.put("cashCreditPercent", credits.signum() > 0
|
row.put("cashCreditPercent", credits.signum() > 0
|
||||||
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
|
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
|
||||||
// Credits and debits as a multiple of what the customer said the business turns over in this window.
|
|
||||||
row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
|
row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
|
||||||
row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
|
row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
|
||||||
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
|
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
|
||||||
|
|
||||||
|
final List<Map<String, Object>> checks = checkRules(rules, txnsByAccount.getOrDefault(accountId, List.of()), limit,
|
||||||
|
declaredForWindow, priorVolume.get(accountId), window);
|
||||||
|
int hit = 0;
|
||||||
|
int noHit = 0;
|
||||||
|
int notChecked = 0;
|
||||||
|
for (final Map<String, Object> c : checks) {
|
||||||
|
final String status = (String) c.get("status");
|
||||||
|
if (HIT.equals(status)) {
|
||||||
|
hit++;
|
||||||
|
} else if (NO_HIT.equals(status)) {
|
||||||
|
noHit++;
|
||||||
|
} else {
|
||||||
|
notChecked++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.put("rulesHit", hit);
|
||||||
|
row.put("rulesNoHit", noHit);
|
||||||
|
row.put("rulesNotChecked", notChecked);
|
||||||
rows.add(row);
|
rows.add(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +227,791 @@ public class AmlCcTrackingApiResource {
|
|||||||
return this.gson.toJson(out);
|
return this.gson.toJson(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── One account ──────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Every credit and debit on one CC account in the window, with the mode and the party on the other side where the
|
||||||
|
* payment recorded them, totalled by mode and by party, an analysis of what it adds up to, and the CC rules checked against
|
||||||
|
* it. The account's own branch must be at or below the caller's.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("{accountId}/trail")
|
||||||
|
public String trail(@PathParam("accountId") final Long accountId, @QueryParam("days") @DefaultValue("30") final int days) {
|
||||||
|
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||||
|
final List<Map<String, Object>> account = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT ca.id, ca.account_no, c.display_name AS client_name, o.name AS branch_name, ca.overdraft_limit,
|
||||||
|
ca.account_balance_derived, ca.allow_overdraft,
|
||||||
|
COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover
|
||||||
|
FROM m_current_account ca
|
||||||
|
LEFT JOIN m_client c ON c.id = ca.client_id
|
||||||
|
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||||
|
LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id)
|
||||||
|
WHERE ca.id = ? AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
||||||
|
""", accountId, hierarchy + "%");
|
||||||
|
if (account.isEmpty()) {
|
||||||
|
throw new NotFoundException("Account not found: " + accountId);
|
||||||
|
}
|
||||||
|
final int window = Math.min(Math.max(days, 1), 366);
|
||||||
|
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
||||||
|
final LocalDate from = asOf.minusDays(window);
|
||||||
|
|
||||||
|
final List<Txn> txns = loadTxns(from, asOf, accountId).getOrDefault(accountId, List.of());
|
||||||
|
|
||||||
|
final Map<String, Object> a = account.get(0);
|
||||||
|
final BigDecimal limit = money(a.get("overdraft_limit"));
|
||||||
|
final BigDecimal balance = money(a.get("account_balance_derived"));
|
||||||
|
final BigDecimal declaredMonthly = a.get("declared_monthly_turnover") == null ? null : money(a.get("declared_monthly_turnover"));
|
||||||
|
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window);
|
||||||
|
|
||||||
|
// Newest first for the list, oldest first for the analysis
|
||||||
|
final List<Map<String, Object>> txnRows = new ArrayList<>();
|
||||||
|
for (int i = txns.size() - 1; i >= 0; i--) {
|
||||||
|
final Txn t = txns.get(i);
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("id", t.id);
|
||||||
|
row.put("date", t.date.toString());
|
||||||
|
row.put("direction", t.credit ? "IN" : "OUT");
|
||||||
|
row.put("amount", t.amount);
|
||||||
|
row.put("mode", t.mode);
|
||||||
|
row.put("isCash", t.cash);
|
||||||
|
row.put("reference", t.reference);
|
||||||
|
row.put("chequeNo", t.chequeNo);
|
||||||
|
row.put("counterpartyAccount", t.partyAccount);
|
||||||
|
row.put("counterpartyBank", t.partyBank);
|
||||||
|
row.put("counterpartyIfsc", t.partyIfsc);
|
||||||
|
row.put("remarks", t.remarks);
|
||||||
|
row.put("balanceAfter", t.balanceAfter);
|
||||||
|
txnRows.add(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> out = new LinkedHashMap<>();
|
||||||
|
out.put("accountNo", a.get("account_no"));
|
||||||
|
out.put("clientName", a.get("client_name"));
|
||||||
|
out.put("branchName", a.get("branch_name"));
|
||||||
|
out.put("isCc", Boolean.TRUE.equals(a.get("allow_overdraft")));
|
||||||
|
out.put("sanctionedLimit", limit);
|
||||||
|
out.put("balance", balance);
|
||||||
|
out.put("declaredMonthlyTurnover", declaredMonthly);
|
||||||
|
out.put("from", from.toString());
|
||||||
|
out.put("asOf", asOf.toString());
|
||||||
|
out.put("days", window);
|
||||||
|
out.put("transactions", txnRows);
|
||||||
|
out.putAll(analyse(txns, limit, declaredForWindow));
|
||||||
|
out.put("ruleChecks", checkRules(loadRules(), txns, limit, declaredForWindow, priorVolumes(from, window).get(accountId), window));
|
||||||
|
return this.gson.toJson(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Loading ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** The window's transactions, oldest first, by account - for one account, or for every current account. */
|
||||||
|
private Map<Long, List<Txn>> loadTxns(final LocalDate from, final LocalDate asOf, final Long accountId) {
|
||||||
|
final String sql = TRAIL_SELECT + (accountId == null ? "" : " AND t.current_account_id = ?")
|
||||||
|
+ " ORDER BY t.transaction_date ASC, t.id ASC";
|
||||||
|
final List<Map<String, Object>> rows = accountId == null
|
||||||
|
? this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf))
|
||||||
|
: this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf), accountId);
|
||||||
|
final Map<Long, List<Txn>> byAccount = new HashMap<>();
|
||||||
|
for (final Map<String, Object> r : rows) {
|
||||||
|
final Txn t = new Txn();
|
||||||
|
t.id = ((Number) r.get("id")).longValue();
|
||||||
|
t.date = ((java.sql.Date) r.get("transaction_date")).toLocalDate();
|
||||||
|
t.credit = ((Number) r.get("transaction_type_enum")).intValue() == 1;
|
||||||
|
t.amount = money(r.get("amount"));
|
||||||
|
t.reference = (String) r.get("ref_no");
|
||||||
|
t.remarks = (String) r.get("remarks");
|
||||||
|
t.balanceAfter = r.get("running_balance_derived") == null ? null : money(r.get("running_balance_derived"));
|
||||||
|
t.cash = Boolean.TRUE.equals(r.get("is_cash"));
|
||||||
|
t.chequeNo = (String) r.get("cheque_no");
|
||||||
|
t.partyAccount = (String) r.get("cp_account");
|
||||||
|
t.partyBank = (String) r.get("cp_bank");
|
||||||
|
t.partyIfsc = (String) r.get("cp_ifsc");
|
||||||
|
t.mode = modeOf(t.cash, (String) r.get("payment_type"), t.chequeNo, t.remarks);
|
||||||
|
t.party = partyOf(t);
|
||||||
|
byAccount.computeIfAbsent(((Number) r.get("current_account_id")).longValue(), k -> new ArrayList<>()).add(t);
|
||||||
|
}
|
||||||
|
return byAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The average turnover (credits plus debits) of each account over the three windows before this one, where there is any. */
|
||||||
|
private Map<Long, BigDecimal> priorVolumes(final LocalDate from, final int window) {
|
||||||
|
final Map<Long, BigDecimal> result = new HashMap<>();
|
||||||
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||||
|
SELECT t.current_account_id, SUM(t.amount) AS total
|
||||||
|
FROM m_current_account_transaction t
|
||||||
|
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
|
||||||
|
AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||||
|
GROUP BY t.current_account_id
|
||||||
|
""", java.sql.Date.valueOf(from.minusDays(3L * window)), java.sql.Date.valueOf(from));
|
||||||
|
for (final Map<String, Object> r : rows) {
|
||||||
|
result.put(((Number) r.get("current_account_id")).longValue(),
|
||||||
|
money(r.get("total")).divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── The CC rules ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One CC rule: the catalog entry and, where there is one, the live-transaction rule behind it. */
|
||||||
|
private static final class CcRule {
|
||||||
|
long id;
|
||||||
|
String name;
|
||||||
|
int riskCategory;
|
||||||
|
String coverage;
|
||||||
|
boolean onTrial;
|
||||||
|
String ruleType;
|
||||||
|
BigDecimal threshold;
|
||||||
|
BigDecimal min;
|
||||||
|
BigDecimal max;
|
||||||
|
Integer windowDays;
|
||||||
|
String direction;
|
||||||
|
boolean cashOnly;
|
||||||
|
Integer minCount;
|
||||||
|
String subjectScope;
|
||||||
|
boolean active;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<CcRule> loadRules() {
|
||||||
|
final List<CcRule> rules = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(RULES_SQL)) {
|
||||||
|
final CcRule rule = new CcRule();
|
||||||
|
rule.id = ((Number) r.get("id")).longValue();
|
||||||
|
rule.name = (String) r.get("rule_name");
|
||||||
|
rule.riskCategory = r.get("risk_category") == null ? 0 : ((Number) r.get("risk_category")).intValue();
|
||||||
|
rule.coverage = (String) r.get("coverage_status");
|
||||||
|
rule.onTrial = Boolean.TRUE.equals(r.get("on_trial"));
|
||||||
|
rule.ruleType = (String) r.get("rule_type");
|
||||||
|
rule.threshold = r.get("threshold_amount") == null ? null : money(r.get("threshold_amount"));
|
||||||
|
rule.min = r.get("min_limit_amount") == null ? null : money(r.get("min_limit_amount"));
|
||||||
|
rule.max = r.get("max_limit_amount") == null ? null : money(r.get("max_limit_amount"));
|
||||||
|
rule.windowDays = r.get("window_days") == null ? null : ((Number) r.get("window_days")).intValue();
|
||||||
|
rule.direction = (String) r.get("direction");
|
||||||
|
rule.cashOnly = Boolean.TRUE.equals(r.get("cash_only"));
|
||||||
|
rule.minCount = r.get("min_transaction_count") == null ? null : ((Number) r.get("min_transaction_count")).intValue();
|
||||||
|
rule.subjectScope = (String) r.get("subject_scope");
|
||||||
|
rule.active = r.get("is_active") == null || Boolean.TRUE.equals(r.get("is_active"));
|
||||||
|
rules.add(rule);
|
||||||
|
}
|
||||||
|
return rules;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Each CC rule against one account's transactions. A rule with a live-transaction rule behind it is checked the way the
|
||||||
|
* engine would check it; a CC scenario is checked by its own test; a rule that turns on an officer's judgement, or that the
|
||||||
|
* system does not yet check, is NOT_CHECKED - never reported as passed.
|
||||||
|
*/
|
||||||
|
private List<Map<String, Object>> checkRules(final List<CcRule> rules, final List<Txn> txns, final BigDecimal limit,
|
||||||
|
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume, final int window) {
|
||||||
|
final Facts f = facts(txns, limit);
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final CcRule rule : rules) {
|
||||||
|
final String[] result;
|
||||||
|
final String kind;
|
||||||
|
if (rule.name != null && rule.name.startsWith("CC - ")) {
|
||||||
|
kind = "CC scenario";
|
||||||
|
result = scenario(rule, f, txns, limit, declaredForWindow, priorAverageVolume);
|
||||||
|
} else if (rule.ruleType != null) {
|
||||||
|
kind = "Transaction rule";
|
||||||
|
result = transactionRule(rule, txns, window);
|
||||||
|
} else if ("MANUAL_REVIEW".equals(rule.coverage)) {
|
||||||
|
kind = "Manual review";
|
||||||
|
result = new String[] { NOT_CHECKED, "Turns on an officer's judgement, so the system does not check it." };
|
||||||
|
} else {
|
||||||
|
kind = "Planned";
|
||||||
|
result = new String[] { NOT_CHECKED, "Planned: the system does not check this yet." };
|
||||||
|
}
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("ruleId", rule.id);
|
||||||
|
m.put("name", rule.name);
|
||||||
|
m.put("kind", kind);
|
||||||
|
m.put("riskCategory", rule.riskCategory);
|
||||||
|
m.put("onTrial", rule.onTrial);
|
||||||
|
m.put("status", result[0]);
|
||||||
|
m.put("detail", result[1]);
|
||||||
|
out.add(m);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A live-transaction rule applied to this account's transactions, with the engine's own meaning of each rule type. */
|
||||||
|
private String[] transactionRule(final CcRule rule, final List<Txn> txns, final int tabWindow) {
|
||||||
|
if (!rule.active) {
|
||||||
|
return new String[] { NOT_CHECKED, "The rule is switched off." };
|
||||||
|
}
|
||||||
|
if (rule.subjectScope != null) {
|
||||||
|
return new String[] { NOT_CHECKED, "Applies to a kind of customer (" + rule.subjectScope + "), not to the transactions." };
|
||||||
|
}
|
||||||
|
if (rule.threshold == null) {
|
||||||
|
return new String[] { NOT_CHECKED, "The rule has no amount, so there is nothing to compare a transaction with." };
|
||||||
|
}
|
||||||
|
final List<Txn> pool = new ArrayList<>();
|
||||||
|
for (final Txn t : txns) {
|
||||||
|
if (rule.direction != null && "CREDIT".equalsIgnoreCase(rule.direction) != t.credit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (rule.cashOnly && !t.cash) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
pool.add(t);
|
||||||
|
}
|
||||||
|
final String scope = (rule.direction == null ? "credit or debit" : rule.direction.toLowerCase()) + (rule.cashOnly ? " cash" : "")
|
||||||
|
+ " transaction";
|
||||||
|
|
||||||
|
if ("THRESHOLD".equals(rule.ruleType)) {
|
||||||
|
Txn largest = null;
|
||||||
|
int count = 0;
|
||||||
|
for (final Txn t : pool) {
|
||||||
|
if (t.amount.compareTo(rule.threshold) >= 0) {
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
if (largest == null || t.amount.compareTo(largest.amount) > 0) {
|
||||||
|
largest = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count > 0) {
|
||||||
|
return new String[] { HIT, count + " " + scope + "(s) of " + rupees(rule.threshold) + " or more; the largest is "
|
||||||
|
+ rupees(largest.amount) + " on " + largest.date + "." };
|
||||||
|
}
|
||||||
|
return new String[] { NO_HIT, largest == null ? "There is no " + scope + " in the window."
|
||||||
|
: "No " + scope + " reaches " + rupees(rule.threshold) + "; the largest is " + rupees(largest.amount) + "." };
|
||||||
|
}
|
||||||
|
|
||||||
|
// STRUCTURING and VELOCITY: enough transactions, each inside the rule's band, adding up to the amount, within its window
|
||||||
|
final int days = rule.windowDays != null && rule.windowDays > 0 ? rule.windowDays : tabWindow;
|
||||||
|
final List<Txn> inBand = new ArrayList<>();
|
||||||
|
for (final Txn t : pool) {
|
||||||
|
if ((rule.min == null || t.amount.compareTo(rule.min) >= 0) && (rule.max == null || t.amount.compareTo(rule.max) <= 0)) {
|
||||||
|
inBand.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final int needed = rule.minCount == null || rule.minCount <= 1 ? 1 : rule.minCount;
|
||||||
|
int bestCount = 0;
|
||||||
|
BigDecimal bestSum = BigDecimal.ZERO;
|
||||||
|
for (final Txn end : inBand) {
|
||||||
|
int count = 0;
|
||||||
|
BigDecimal sum = BigDecimal.ZERO;
|
||||||
|
for (final Txn t : inBand) {
|
||||||
|
if (!t.date.isAfter(end.date) && t.date.isAfter(end.date.minusDays(days))) {
|
||||||
|
count++;
|
||||||
|
sum = sum.add(t.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (count >= needed && sum.compareTo(rule.threshold) >= 0) {
|
||||||
|
return new String[] { HIT, count + " " + scope + "(s) within " + days + " days add up to " + rupees(sum)
|
||||||
|
+ " (the rule fires at " + rupees(rule.threshold) + " from " + needed + " or more)." };
|
||||||
|
}
|
||||||
|
if (sum.compareTo(bestSum) > 0) {
|
||||||
|
bestSum = sum;
|
||||||
|
bestCount = count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new String[] { NO_HIT, inBand.isEmpty() ? "No " + scope + " inside the rule's band in the window."
|
||||||
|
: "At most " + bestCount + " " + scope + "(s) within " + days + " days, adding up to " + rupees(bestSum)
|
||||||
|
+ "; the rule needs " + needed + " or more adding up to " + rupees(rule.threshold) + "." };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The CC scenarios written for this tab, each by its own test. */
|
||||||
|
private String[] scenario(final CcRule rule, final Facts f, final List<Txn> txns, final BigDecimal limit,
|
||||||
|
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume) {
|
||||||
|
final String n = rule.name;
|
||||||
|
if (txns.isEmpty()) {
|
||||||
|
return new String[] { NO_HIT, "There is no activity on the account in the window." };
|
||||||
|
}
|
||||||
|
if (n.contains("credit turnover exceeds")) {
|
||||||
|
return turnover(f.in, "Credits (Cr)", declaredForWindow, limit);
|
||||||
|
}
|
||||||
|
if (n.contains("debit turnover exceeds")) {
|
||||||
|
return turnover(f.out, "Debits (Dr)", declaredForWindow, limit);
|
||||||
|
}
|
||||||
|
if (n.contains("substantially withdrawn within")) {
|
||||||
|
return f.rapidCount > 0
|
||||||
|
? new String[] { HIT, f.rapidCount + " credit(s) worth " + rupees(f.rapidValue) + " were mostly paid out within "
|
||||||
|
+ RAPID_DAYS + " days." }
|
||||||
|
: new String[] { NO_HIT, "No credit was mostly paid out again within " + RAPID_DAYS + " days." };
|
||||||
|
}
|
||||||
|
if (n.contains("Sudden spike")) {
|
||||||
|
if (priorAverageVolume == null || priorAverageVolume.signum() <= 0) {
|
||||||
|
return new String[] { NOT_CHECKED, "There is no earlier activity to compare this window with." };
|
||||||
|
}
|
||||||
|
final BigDecimal now = f.in.add(f.out);
|
||||||
|
final BigDecimal multiple = now.divide(priorAverageVolume, 2, RoundingMode.HALF_UP);
|
||||||
|
final String text = "This window moved " + rupees(now) + ", " + multiple + "x the account's own earlier average of "
|
||||||
|
+ rupees(priorAverageVolume) + ".";
|
||||||
|
return multiple.compareTo(SPIKE_MULTIPLE) >= 0 ? new String[] { HIT, text } : new String[] { NO_HIT, text };
|
||||||
|
}
|
||||||
|
if (n.contains("share of cash")) {
|
||||||
|
final BigDecimal inShare = f.in.signum() > 0 ? f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
||||||
|
final BigDecimal outShare = f.out.signum() > 0 ? f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
||||||
|
final String text = "Cash is " + percent(inShare) + " of credits and " + percent(outShare) + " of debits.";
|
||||||
|
return inShare.compareTo(CASH_SHARE) >= 0 || outShare.compareTo(CASH_SHARE) >= 0 ? new String[] { HIT, text }
|
||||||
|
: new String[] { NO_HIT, text };
|
||||||
|
}
|
||||||
|
if (n.contains("round tripping")) {
|
||||||
|
if (limit.signum() <= 0) {
|
||||||
|
return new String[] { NOT_CHECKED, "The account has no sanctioned limit." };
|
||||||
|
}
|
||||||
|
if (f.peakUse.compareTo(LIMIT_USE) >= 0 && f.clearedAfterPeak) {
|
||||||
|
return new String[] { HIT, "The limit was drawn to " + percent(f.peakUse) + " and the account was back in credit within "
|
||||||
|
+ CLEARED_DAYS + " days." };
|
||||||
|
}
|
||||||
|
return new String[] { NO_HIT, "The limit was drawn to at most " + percent(f.peakUse)
|
||||||
|
+ (f.peakUse.compareTo(LIMIT_USE) >= 0 ? ", but the account did not return to credit within " + CLEARED_DAYS + " days." : ".") };
|
||||||
|
}
|
||||||
|
if (n.contains("counterparty concentration")) {
|
||||||
|
if (f.top == null) {
|
||||||
|
return new String[] { NOT_CHECKED, "No transaction recorded the party on the other side." };
|
||||||
|
}
|
||||||
|
final String text = f.top.label + " handled " + percent(f.topShare) + " of the money moved.";
|
||||||
|
return f.topShare.compareTo(CONCENTRATION_SHARE) >= 0 && f.top.count >= 3 ? new String[] { HIT, text }
|
||||||
|
: new String[] { NO_HIT, text };
|
||||||
|
}
|
||||||
|
if (n.contains("Circular movement")) {
|
||||||
|
if (f.twoWay.isEmpty()) {
|
||||||
|
return new String[] { NO_HIT, "No party both paid in and was paid a similar amount." };
|
||||||
|
}
|
||||||
|
final Total first = f.twoWay.get(0);
|
||||||
|
return new String[] { HIT, first.label + " is on both sides: " + rupees(first.in) + " Cr and " + rupees(first.out) + " Dr." };
|
||||||
|
}
|
||||||
|
return new String[] { NOT_CHECKED, "Needs the customer's relationship data, which the system does not hold yet." };
|
||||||
|
}
|
||||||
|
|
||||||
|
private String[] turnover(final BigDecimal amount, final String label, final BigDecimal declaredForWindow, final BigDecimal limit) {
|
||||||
|
final BigDecimal vsDeclared = ratio(amount, declaredForWindow);
|
||||||
|
final BigDecimal vsLimit = ratio(amount, limit);
|
||||||
|
if (vsDeclared == null && vsLimit == null) {
|
||||||
|
return new String[] { NOT_CHECKED, "The customer has declared no turnover and the account has no sanctioned limit." };
|
||||||
|
}
|
||||||
|
final boolean hit = (vsDeclared != null && vsDeclared.compareTo(TURNOVER_MULTIPLE) >= 0)
|
||||||
|
|| (vsLimit != null && vsLimit.compareTo(TURNOVER_MULTIPLE) >= 0);
|
||||||
|
final String text = label + " of " + rupees(amount)
|
||||||
|
+ (vsDeclared != null ? " are " + vsDeclared + "x the declared turnover for the window" : "")
|
||||||
|
+ (vsLimit != null ? (vsDeclared != null ? " and " : " are ") + vsLimit + "x the sanctioned limit" : "") + ".";
|
||||||
|
return new String[] { hit ? HIT : NO_HIT, text };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Analysis ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** One posted credit or debit, as the analysis reads it. */
|
||||||
|
private static final class Txn {
|
||||||
|
long id;
|
||||||
|
LocalDate date;
|
||||||
|
boolean credit;
|
||||||
|
BigDecimal amount;
|
||||||
|
String reference;
|
||||||
|
String remarks;
|
||||||
|
BigDecimal balanceAfter;
|
||||||
|
boolean cash;
|
||||||
|
String chequeNo;
|
||||||
|
String partyAccount;
|
||||||
|
String partyBank;
|
||||||
|
String partyIfsc;
|
||||||
|
String mode;
|
||||||
|
String party;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Running in / out / count for one mode or party. */
|
||||||
|
private static final class Total {
|
||||||
|
String label;
|
||||||
|
String sub;
|
||||||
|
BigDecimal in = BigDecimal.ZERO;
|
||||||
|
BigDecimal out = BigDecimal.ZERO;
|
||||||
|
int count;
|
||||||
|
|
||||||
|
BigDecimal volume() {
|
||||||
|
return in.add(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean unrecorded() {
|
||||||
|
return sub == null && "Not recorded".equals(label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The figures the observations and the CC scenarios are both judged on, worked out once. */
|
||||||
|
private static final class Facts {
|
||||||
|
BigDecimal in = BigDecimal.ZERO;
|
||||||
|
BigDecimal out = BigDecimal.ZERO;
|
||||||
|
BigDecimal cashIn = BigDecimal.ZERO;
|
||||||
|
BigDecimal cashOut = BigDecimal.ZERO;
|
||||||
|
BigDecimal volume = BigDecimal.ZERO;
|
||||||
|
BigDecimal lowest;
|
||||||
|
BigDecimal peakUse = BigDecimal.ZERO;
|
||||||
|
boolean clearedAfterPeak;
|
||||||
|
int rapidCount;
|
||||||
|
BigDecimal rapidValue = BigDecimal.ZERO;
|
||||||
|
Map<String, Total> byParty = new LinkedHashMap<>();
|
||||||
|
Total top;
|
||||||
|
BigDecimal topShare = BigDecimal.ZERO;
|
||||||
|
List<Total> twoWay = new ArrayList<>();
|
||||||
|
BigDecimal unrecorded = BigDecimal.ZERO;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Facts facts(final List<Txn> txns, final BigDecimal limit) {
|
||||||
|
final Facts f = new Facts();
|
||||||
|
Txn lowestTxn = null;
|
||||||
|
for (final Txn t : txns) {
|
||||||
|
if (t.credit) {
|
||||||
|
f.in = f.in.add(t.amount);
|
||||||
|
if (t.cash) {
|
||||||
|
f.cashIn = f.cashIn.add(t.amount);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
f.out = f.out.add(t.amount);
|
||||||
|
if (t.cash) {
|
||||||
|
f.cashOut = f.cashOut.add(t.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.balanceAfter != null && (f.lowest == null || t.balanceAfter.compareTo(f.lowest) < 0)) {
|
||||||
|
f.lowest = t.balanceAfter;
|
||||||
|
lowestTxn = t;
|
||||||
|
}
|
||||||
|
add(f.byParty, t.party, t.party, t.partyBank, t);
|
||||||
|
}
|
||||||
|
f.volume = f.in.add(f.out);
|
||||||
|
|
||||||
|
// How hard the limit was used, and whether the account was back in credit soon after its worst point
|
||||||
|
if (limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0) {
|
||||||
|
f.peakUse = f.lowest.negate().divide(limit, 4, RoundingMode.HALF_UP);
|
||||||
|
for (final Txn t : txns) {
|
||||||
|
if (lowestTxn != null && t.id != lowestTxn.id && t.balanceAfter != null && t.balanceAfter.signum() >= 0
|
||||||
|
&& !t.date.isBefore(lowestTxn.date) && !t.date.isAfter(lowestTxn.date.plusDays(CLEARED_DAYS))) {
|
||||||
|
f.clearedAfterPeak = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Money in and straight out again. Each debit is set against one credit only - the earliest still unmatched that it
|
||||||
|
// falls within RAPID_DAYS of - so one payment out is not counted as the exit of several credits.
|
||||||
|
final List<Txn> credits = new ArrayList<>();
|
||||||
|
final Map<Long, BigDecimal> matched = new LinkedHashMap<>();
|
||||||
|
for (final Txn t : txns) {
|
||||||
|
if (t.credit) {
|
||||||
|
credits.add(t);
|
||||||
|
matched.put(t.id, BigDecimal.ZERO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final Txn d : txns) {
|
||||||
|
if (d.credit) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
BigDecimal toPlace = d.amount;
|
||||||
|
for (final Txn c : credits) {
|
||||||
|
if (toPlace.signum() <= 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (d.date.isBefore(c.date) || d.date.isAfter(c.date.plusDays(RAPID_DAYS))) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal room = c.amount.subtract(matched.get(c.id));
|
||||||
|
if (room.signum() <= 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
final BigDecimal placed = room.min(toPlace);
|
||||||
|
matched.put(c.id, matched.get(c.id).add(placed));
|
||||||
|
toPlace = toPlace.subtract(placed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (final Txn c : credits) {
|
||||||
|
if (matched.get(c.id).compareTo(c.amount.multiply(RAPID_SHARE)) >= 0) {
|
||||||
|
f.rapidCount++;
|
||||||
|
f.rapidValue = f.rapidValue.add(c.amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The party handling the most, the parties on both sides, and what has no party recorded
|
||||||
|
for (final Total t : f.byParty.values()) {
|
||||||
|
if (t.unrecorded()) {
|
||||||
|
f.unrecorded = f.unrecorded.add(t.volume());
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (f.top == null || t.volume().compareTo(f.top.volume()) > 0) {
|
||||||
|
f.top = t;
|
||||||
|
}
|
||||||
|
if (t.in.signum() > 0 && t.out.signum() > 0
|
||||||
|
&& t.in.min(t.out).divide(t.in.max(t.out), 4, RoundingMode.HALF_UP).compareTo(TWO_WAY_SHARE) >= 0) {
|
||||||
|
f.twoWay.add(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (f.top != null && f.volume.signum() > 0) {
|
||||||
|
f.topShare = f.top.volume().divide(f.volume, 4, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> analyse(final List<Txn> txns, final BigDecimal limit, final BigDecimal declaredForWindow) {
|
||||||
|
final Facts f = facts(txns, limit);
|
||||||
|
int inCount = 0;
|
||||||
|
int outCount = 0;
|
||||||
|
Txn largestIn = null;
|
||||||
|
Txn largestOut = null;
|
||||||
|
BigDecimal highest = null;
|
||||||
|
int belowZero = 0;
|
||||||
|
final Map<String, Total> byMode = new LinkedHashMap<>();
|
||||||
|
final TreeMap<LocalDate, Total> byWeek = new TreeMap<>();
|
||||||
|
final java.util.Set<LocalDate> days = new java.util.HashSet<>();
|
||||||
|
|
||||||
|
for (final Txn t : txns) {
|
||||||
|
days.add(t.date);
|
||||||
|
if (t.credit) {
|
||||||
|
inCount++;
|
||||||
|
if (largestIn == null || t.amount.compareTo(largestIn.amount) > 0) {
|
||||||
|
largestIn = t;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
outCount++;
|
||||||
|
if (largestOut == null || t.amount.compareTo(largestOut.amount) > 0) {
|
||||||
|
largestOut = t;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (t.balanceAfter != null) {
|
||||||
|
highest = highest == null || t.balanceAfter.compareTo(highest) > 0 ? t.balanceAfter : highest;
|
||||||
|
if (t.balanceAfter.signum() < 0) {
|
||||||
|
belowZero++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
add(byMode, t.mode, t.mode, null, t);
|
||||||
|
final LocalDate weekStart = t.date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||||
|
add(byWeek, weekStart, weekStart.toString(), null, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, Object> result = new LinkedHashMap<>();
|
||||||
|
final Map<String, Object> summary = new LinkedHashMap<>();
|
||||||
|
summary.put("in", f.in);
|
||||||
|
summary.put("out", f.out);
|
||||||
|
summary.put("net", f.in.subtract(f.out));
|
||||||
|
summary.put("inCount", inCount);
|
||||||
|
summary.put("outCount", outCount);
|
||||||
|
summary.put("averageIn", inCount == 0 ? null : f.in.divide(BigDecimal.valueOf(inCount), 2, RoundingMode.HALF_UP));
|
||||||
|
summary.put("averageOut", outCount == 0 ? null : f.out.divide(BigDecimal.valueOf(outCount), 2, RoundingMode.HALF_UP));
|
||||||
|
summary.put("largestIn", largest(largestIn));
|
||||||
|
summary.put("largestOut", largest(largestOut));
|
||||||
|
summary.put("activeDays", days.size());
|
||||||
|
summary.put("lowestBalance", f.lowest);
|
||||||
|
summary.put("highestBalance", highest);
|
||||||
|
summary.put("timesBelowZero", belowZero);
|
||||||
|
summary.put("peakLimitUsedPercent", limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0
|
||||||
|
? f.lowest.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
||||||
|
summary.put("cashIn", f.cashIn);
|
||||||
|
summary.put("cashOut", f.cashOut);
|
||||||
|
summary.put("creditsVsDeclared", ratio(f.in, declaredForWindow));
|
||||||
|
summary.put("debitsVsDeclared", ratio(f.out, declaredForWindow));
|
||||||
|
summary.put("declaredForWindow", declaredForWindow);
|
||||||
|
result.put("summary", summary);
|
||||||
|
|
||||||
|
result.put("byMode", totals(byMode, f.volume));
|
||||||
|
result.put("byCounterparty", totals(f.byParty, f.volume));
|
||||||
|
result.put("timeline", totals(byWeek, f.volume));
|
||||||
|
result.put("observations", observations(txns, f, limit, declaredForWindow));
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <K> void add(final Map<K, Total> map, final K key, final String label, final String sub, final Txn t) {
|
||||||
|
final Total total = map.computeIfAbsent(key, k -> {
|
||||||
|
final Total fresh = new Total();
|
||||||
|
fresh.label = label;
|
||||||
|
fresh.sub = sub;
|
||||||
|
return fresh;
|
||||||
|
});
|
||||||
|
if (t.credit) {
|
||||||
|
total.in = total.in.add(t.amount);
|
||||||
|
} else {
|
||||||
|
total.out = total.out.add(t.amount);
|
||||||
|
}
|
||||||
|
total.count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static <K> List<Map<String, Object>> totals(final Map<K, Total> map, final BigDecimal volume) {
|
||||||
|
final List<Total> list = new ArrayList<>(map.values());
|
||||||
|
// Modes and parties: biggest first. The timeline map is already in date order and keeps it.
|
||||||
|
if (!(map instanceof TreeMap)) {
|
||||||
|
list.sort(Comparator.comparing(Total::volume).reversed());
|
||||||
|
}
|
||||||
|
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||||
|
for (final Total t : list) {
|
||||||
|
final Map<String, Object> row = new LinkedHashMap<>();
|
||||||
|
row.put("label", t.label);
|
||||||
|
row.put("sub", t.sub);
|
||||||
|
row.put("in", t.in);
|
||||||
|
row.put("out", t.out);
|
||||||
|
row.put("count", t.count);
|
||||||
|
row.put("sharePercent", volume.signum() > 0
|
||||||
|
? t.volume().multiply(BigDecimal.valueOf(100)).divide(volume, 1, RoundingMode.HALF_UP) : null);
|
||||||
|
rows.add(row);
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, Object> largest(final Txn t) {
|
||||||
|
if (t == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("amount", t.amount);
|
||||||
|
m.put("date", t.date.toString());
|
||||||
|
m.put("party", t.party);
|
||||||
|
m.put("mode", t.mode);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What stands out, with the figures behind it. Starting values only - see the class note. */
|
||||||
|
private List<Map<String, Object>> observations(final List<Txn> txns, final Facts f, final BigDecimal limit,
|
||||||
|
final BigDecimal declaredForWindow) {
|
||||||
|
final List<Map<String, Object>> list = new ArrayList<>();
|
||||||
|
|
||||||
|
if (txns.isEmpty()) {
|
||||||
|
list.add(observation("INFO", "No activity", "There are no credits (Cr) or debits (Dr) on this account in the window."));
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (declaredForWindow == null) {
|
||||||
|
list.add(observation("INFO", "Declared turnover is missing",
|
||||||
|
"The customer has not declared a turnover, so the activity cannot be compared with the business profile."));
|
||||||
|
} else {
|
||||||
|
final BigDecimal inMultiple = ratio(f.in, declaredForWindow);
|
||||||
|
final BigDecimal outMultiple = ratio(f.out, declaredForWindow);
|
||||||
|
if (inMultiple != null && inMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
|
||||||
|
list.add(observation("HIGH", "Credits far above the declared turnover", "Credits (Cr) of " + rupees(f.in) + " are "
|
||||||
|
+ inMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
|
||||||
|
}
|
||||||
|
if (outMultiple != null && outMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
|
||||||
|
list.add(observation("HIGH", "Debits far above the declared turnover", "Debits (Dr) of " + rupees(f.out) + " are "
|
||||||
|
+ outMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.rapidCount > 0) {
|
||||||
|
list.add(observation("MEDIUM", "Money paid out soon after it came in", f.rapidCount + " credit(s) (Cr) worth "
|
||||||
|
+ rupees(f.rapidValue) + " were mostly (" + RAPID_SHARE.multiply(BigDecimal.valueOf(100)).intValue()
|
||||||
|
+ "% or more) paid out again within " + RAPID_DAYS + " days."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.top != null && f.topShare.compareTo(CONCENTRATION_SHARE) >= 0) {
|
||||||
|
list.add(observation("MEDIUM", "One party handles most of the money", f.top.label
|
||||||
|
+ (f.top.sub == null ? "" : " (" + f.top.sub + ")") + " accounts for " + percent(f.topShare) + " of everything moved ("
|
||||||
|
+ rupees(f.top.in) + " Cr, " + rupees(f.top.out) + " Dr)."));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final Total t : f.twoWay) {
|
||||||
|
list.add(observation("HIGH", "Same party on both sides", t.label + " is on both sides: credits (Cr) of " + rupees(t.in)
|
||||||
|
+ " came from it and debits (Dr) of " + rupees(t.out) + " went to it."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.in.signum() > 0 && f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
|
||||||
|
list.add(observation("MEDIUM", "High share of cash in credits", rupees(f.cashIn) + " of " + rupees(f.in)
|
||||||
|
+ " credited came in as cash."));
|
||||||
|
}
|
||||||
|
if (f.out.signum() > 0 && f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
|
||||||
|
list.add(observation("MEDIUM", "High share of cash in debits", rupees(f.cashOut) + " of " + rupees(f.out)
|
||||||
|
+ " debited went out as cash."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (limit.signum() > 0 && f.peakUse.compareTo(LIMIT_USE) >= 0) {
|
||||||
|
list.add(observation("MEDIUM", "Sanctioned limit nearly used", "The account went down to " + rupees(f.lowest.negate())
|
||||||
|
+ " Dr, which is " + percent(f.peakUse) + " of the limit."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (f.volume.signum() > 0 && f.unrecorded.divide(f.volume, 4, RoundingMode.HALF_UP).compareTo(UNRECORDED_SHARE) >= 0) {
|
||||||
|
list.add(observation("INFO", "Other side not recorded", rupees(f.unrecorded) + " of the " + rupees(f.volume)
|
||||||
|
+ " moved has no counterparty recorded, so where it came from or went cannot be told."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (list.isEmpty()) {
|
||||||
|
list.add(observation("OK", "Nothing stands out", "None of the starting checks was triggered for this window."));
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why each kind of observation is worth a look, shown under it so an officer does not have to guess. */
|
||||||
|
private static final Map<String, String> WHY = Map.of(
|
||||||
|
"Declared turnover is missing",
|
||||||
|
"Without a declared turnover the bank cannot tell whether this activity is normal for the customer's business.",
|
||||||
|
"Credits far above the declared turnover",
|
||||||
|
"Money received well beyond what the business says it turns over can mean the account is used for something other than "
|
||||||
|
+ "the declared business.",
|
||||||
|
"Debits far above the declared turnover",
|
||||||
|
"Money paid out well beyond the declared turnover can mean funds are being moved on rather than spent in the business.",
|
||||||
|
"Money paid out soon after it came in",
|
||||||
|
"Funds that come in and go straight out within days can mean the account is only a pass-through.",
|
||||||
|
"One party handles most of the money",
|
||||||
|
"Depending on one party concentrates risk and can mean the account mainly serves that party.",
|
||||||
|
"Same party on both sides",
|
||||||
|
"Money going to a party and coming back from it can be a round trip with no real business behind it.",
|
||||||
|
"High share of cash in credits",
|
||||||
|
"Cash is harder to trace to its source than a bank transfer, so a high share of it deserves a look.",
|
||||||
|
"High share of cash in debits",
|
||||||
|
"Large cash withdrawals leave no trail of where the money went.",
|
||||||
|
"Sanctioned limit nearly used",
|
||||||
|
"An account drawn close to its sanctioned limit has little room left and may be under strain or being used up fast.",
|
||||||
|
"Other side not recorded",
|
||||||
|
"Without the party on the other side the bank cannot tell where the money came from or went to.");
|
||||||
|
|
||||||
|
private static Map<String, Object> observation(final String level, final String title, final String detail) {
|
||||||
|
final Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("level", level);
|
||||||
|
m.put("title", title);
|
||||||
|
m.put("detail", detail);
|
||||||
|
m.put("why", WHY.get(title));
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String rupees(final BigDecimal value) {
|
||||||
|
return "₹" + String.format("%,.2f", value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String percent(final BigDecimal fraction) {
|
||||||
|
return fraction.multiply(BigDecimal.valueOf(100)).setScale(1, RoundingMode.HALF_UP) + "%";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Mode and party ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How a payment moved. The bank's payment types are only Cash and Transfer, so a cheque, a demand draft, NEFT, RTGS, IMPS,
|
||||||
|
* UPI or an internal transfer is told from the cheque number the payment recorded and from the words in its payment type
|
||||||
|
* and remarks. Where nothing says, it is the payment type itself.
|
||||||
|
*/
|
||||||
|
private static String modeOf(final boolean cash, final String paymentType, final String chequeNo, final String remarks) {
|
||||||
|
if (cash) {
|
||||||
|
return "Cash";
|
||||||
|
}
|
||||||
|
final String text = ((paymentType == null ? "" : paymentType) + " " + (remarks == null ? "" : remarks)).toUpperCase();
|
||||||
|
if (hasWord(text, "RTGS")) {
|
||||||
|
return "RTGS";
|
||||||
|
}
|
||||||
|
if (hasWord(text, "NEFT")) {
|
||||||
|
return "NEFT";
|
||||||
|
}
|
||||||
|
if (hasWord(text, "IMPS")) {
|
||||||
|
return "IMPS";
|
||||||
|
}
|
||||||
|
if (hasWord(text, "UPI")) {
|
||||||
|
return "UPI";
|
||||||
|
}
|
||||||
|
if (hasWord(text, "DEMAND DRAFT") || hasWord(text, "DD")) {
|
||||||
|
return "Demand Draft";
|
||||||
|
}
|
||||||
|
if (chequeNo != null && !chequeNo.isBlank() || hasWord(text, "CHEQUE") || hasWord(text, "CHECK")) {
|
||||||
|
return "Cheque";
|
||||||
|
}
|
||||||
|
if (hasWord(text, "INTERNAL TRANSFER")) {
|
||||||
|
return "Internal transfer";
|
||||||
|
}
|
||||||
|
return paymentType == null || paymentType.isBlank() ? "Not recorded" : paymentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether {@code word} stands alone in {@code text}, so "UPI" is not found inside some other word. */
|
||||||
|
private static boolean hasWord(final String text, final String word) {
|
||||||
|
return java.util.regex.Pattern.compile("(^|[^A-Z0-9])" + java.util.regex.Pattern.quote(word) + "([^A-Z0-9]|$)").matcher(text)
|
||||||
|
.find();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The party on the other side, named by the account the payment recorded; "Not recorded" when it recorded none. */
|
||||||
|
private static String partyOf(final Txn t) {
|
||||||
|
return t.partyAccount == null || t.partyAccount.isBlank() ? "Not recorded" : t.partyAccount;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BigDecimal scaledToWindow(final BigDecimal declaredMonthly, final int window) {
|
||||||
|
return declaredMonthly == null ? null
|
||||||
|
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
|
||||||
|
}
|
||||||
|
|
||||||
private static BigDecimal money(final Object value) {
|
private static BigDecimal money(final Object value) {
|
||||||
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
|
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
-67
@@ -1,67 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.api;
|
|
||||||
|
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
|
||||||
import jakarta.ws.rs.DefaultValue;
|
|
||||||
import jakarta.ws.rs.GET;
|
|
||||||
import jakarta.ws.rs.Path;
|
|
||||||
import jakarta.ws.rs.Produces;
|
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.CacheControl;
|
|
||||||
import jakarta.ws.rs.core.Context;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
|
||||||
import jakarta.ws.rs.core.Response;
|
|
||||||
import jakarta.ws.rs.sse.Sse;
|
|
||||||
import jakarta.ws.rs.sse.SseEventSink;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
|
|
||||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
|
|
||||||
* Numbers only: opening a tab still loads that tab's own list. The counting is in
|
|
||||||
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
|
|
||||||
*/
|
|
||||||
@Path("/v1/approvals/counts")
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public class ApprovalCountsApiResource {
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final ApprovalCountsReadPlatformService readService;
|
|
||||||
private final ApprovalCountsStreamService streamService;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
|
|
||||||
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
|
|
||||||
final AppUser user = this.context.authenticatedUser();
|
|
||||||
final CacheControl noStore = new CacheControl();
|
|
||||||
noStore.setNoStore(true);
|
|
||||||
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
|
|
||||||
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("stream")
|
|
||||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
|
||||||
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
|
|
||||||
// Ask reverse proxies not to hold the events back.
|
|
||||||
response.setHeader("X-Accel-Buffering", "no");
|
|
||||||
response.setHeader("Cache-Control", "no-cache, no-store");
|
|
||||||
try {
|
|
||||||
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
sink.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-14
@@ -1,14 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One Approval Center tab and the question that counts what waits on it.
|
|
||||||
*
|
|
||||||
* @param key
|
|
||||||
* the tab's route segment, which is also the key its badge reads on the screen
|
|
||||||
* @param permission
|
|
||||||
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
|
|
||||||
* the tab
|
|
||||||
* @param sql
|
|
||||||
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
|
|
||||||
*/
|
|
||||||
public record ApprovalCounter(String key, String permission, String sql) {}
|
|
||||||
-27
@@ -1,27 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
import com.google.gson.Gson;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
|
|
||||||
* exactly when the numbers are, which is how a change is told from a repeat.
|
|
||||||
*/
|
|
||||||
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
|
|
||||||
|
|
||||||
private static final Gson GSON = new Gson();
|
|
||||||
|
|
||||||
public static ApprovalCountsData of(final Map<String, Long> counts) {
|
|
||||||
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
|
|
||||||
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
|
|
||||||
}
|
|
||||||
|
|
||||||
public String toJson() {
|
|
||||||
final Map<String, Object> json = new LinkedHashMap<>();
|
|
||||||
json.put("total", this.total);
|
|
||||||
json.put("counts", this.counts);
|
|
||||||
json.put("version", this.version);
|
|
||||||
return GSON.toJson(json);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-22
@@ -1,22 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.data;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
|
|
||||||
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
|
|
||||||
* be used from any thread.
|
|
||||||
*
|
|
||||||
* @param tenant
|
|
||||||
* the tenant the counts are read from
|
|
||||||
* @param visible
|
|
||||||
* the tabs this caller may see, in the order the Approval Center shows them
|
|
||||||
* @param params
|
|
||||||
* the named parameters the tabs' queries use
|
|
||||||
* @param group
|
|
||||||
* equal for callers who are counted identically, so they can share one query
|
|
||||||
*/
|
|
||||||
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
|
|
||||||
String group) {}
|
|
||||||
-122
@@ -1,122 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import java.util.List;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
|
|
||||||
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
|
|
||||||
* tab, add one line here and one badge on the screen.
|
|
||||||
*
|
|
||||||
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
|
|
||||||
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
|
|
||||||
*/
|
|
||||||
public final class ApprovalCounterRegistry {
|
|
||||||
|
|
||||||
private ApprovalCounterRegistry() {}
|
|
||||||
|
|
||||||
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
|
|
||||||
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
|
||||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
|
|
||||||
|
|
||||||
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
|
|
||||||
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
|
||||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
|
|
||||||
|
|
||||||
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
|
|
||||||
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
|
|
||||||
|
|
||||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
|
||||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
|
||||||
|
|
||||||
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
|
||||||
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
|
||||||
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
|
||||||
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
|
|
||||||
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
|
|
||||||
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
|
|
||||||
+ "AND p.entity_name = aud.entity_name)) ";
|
|
||||||
|
|
||||||
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
|
|
||||||
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
|
|
||||||
+ "AND o.hierarchy LIKE :h)";
|
|
||||||
|
|
||||||
/** The one tab whose list is open to managers and head cashiers only. */
|
|
||||||
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
|
|
||||||
|
|
||||||
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
|
|
||||||
public static final List<ApprovalCounter> ALL = List.of(
|
|
||||||
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
|
||||||
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
|
||||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
|
||||||
new ApprovalCounter("client-approval", "READ_CLIENT",
|
|
||||||
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
|
||||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
|
||||||
new ApprovalCounter("loan-approval", "READ_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
|
||||||
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
|
|
||||||
new ApprovalCounter("loan-disbursal", "READ_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
|
||||||
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
|
|
||||||
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
|
|
||||||
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
|
|
||||||
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
|
|
||||||
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
|
|
||||||
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
|
||||||
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
|
||||||
+ "AND COALESCE(ca.terminate_true, false) = false "
|
|
||||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
|
||||||
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
|
||||||
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
|
||||||
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
|
||||||
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
|
||||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
|
||||||
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
|
||||||
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
|
||||||
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
|
|
||||||
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
|
|
||||||
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
|
|
||||||
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
|
|
||||||
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
|
|
||||||
+ "WHERE p.status = 100"),
|
|
||||||
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
|
|
||||||
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
|
|
||||||
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
|
|
||||||
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dds-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
|
||||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
|
||||||
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
|
||||||
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
|
||||||
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
|
||||||
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-issue-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-reprint-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("dd-revoke-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
|
|
||||||
new ApprovalCounter("risk-change-approvals", null,
|
|
||||||
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
|
|
||||||
}
|
|
||||||
-24
@@ -1,24 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
|
|
||||||
/** Reads how many requests wait on each Approval Center tab, in one statement. */
|
|
||||||
public interface ApprovalCountsReadPlatformService {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
|
|
||||||
* tenant of the current request.
|
|
||||||
*/
|
|
||||||
ApprovalCountsPlan planFor(AppUser user);
|
|
||||||
|
|
||||||
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
|
|
||||||
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
|
|
||||||
* cost the database one query. {@code fresh} skips the held answer.
|
|
||||||
*/
|
|
||||||
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
|
|
||||||
}
|
|
||||||
-114
@@ -1,114 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.apache.fineract.useradministration.domain.AppUser;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
|
||||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
|
|
||||||
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
|
|
||||||
|
|
||||||
/** Screens asking together read one answer instead of each running the statement. */
|
|
||||||
private static final long HOLD_MILLIS = 3_000L;
|
|
||||||
|
|
||||||
private record Held(long at, ApprovalCountsData data) {}
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
private final Map<String, Held> held = new ConcurrentHashMap<>();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsPlan planFor(final AppUser user) {
|
|
||||||
final List<ApprovalCounter> visible = new ArrayList<>();
|
|
||||||
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
|
|
||||||
if (isVisible(user, counter)) {
|
|
||||||
visible.add(counter);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final String hierarchy = user.getOffice().getHierarchy();
|
|
||||||
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
|
|
||||||
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
|
|
||||||
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
|
|
||||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
|
||||||
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
|
|
||||||
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
|
|
||||||
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
|
|
||||||
return new ApprovalCountsPlan(tenant, visible, params, group);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
|
|
||||||
final Map<String, Long> counts = new LinkedHashMap<>();
|
|
||||||
final List<ApprovalCounter> visible = plan.visible();
|
|
||||||
if (!visible.isEmpty()) {
|
|
||||||
final StringBuilder sql = new StringBuilder("SELECT ");
|
|
||||||
for (int i = 0; i < visible.size(); i++) {
|
|
||||||
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
|
|
||||||
}
|
|
||||||
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
|
|
||||||
plan.params());
|
|
||||||
for (int i = 0; i < visible.size(); i++) {
|
|
||||||
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
|
|
||||||
if (n > 0) {
|
|
||||||
counts.put(visible.get(i).key(), n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ApprovalCountsData.of(counts);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
|
|
||||||
final ApprovalCountsPlan plan = planFor(user);
|
|
||||||
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
|
|
||||||
final long now = System.currentTimeMillis();
|
|
||||||
final Held recent = this.held.get(holdKey);
|
|
||||||
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
|
|
||||||
return recent.data();
|
|
||||||
}
|
|
||||||
final ApprovalCountsData data = retrieve(plan);
|
|
||||||
this.held.put(holdKey, new Held(now, data));
|
|
||||||
if (this.held.size() > 500) {
|
|
||||||
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
|
|
||||||
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
|
|
||||||
if (counter.permission() != null) {
|
|
||||||
final boolean allowed = counter.permission().startsWith("READ_")
|
|
||||||
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
|
|
||||||
: user.hasAnyPermission(counter.permission());
|
|
||||||
if (!allowed) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean isChecker(final AppUser user) {
|
|
||||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
|
||||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-145
@@ -1,145 +0,0 @@
|
|||||||
package org.apache.fineract.portfolio.approvals.service;
|
|
||||||
|
|
||||||
import jakarta.annotation.PostConstruct;
|
|
||||||
import jakarta.annotation.PreDestroy;
|
|
||||||
import jakarta.ws.rs.sse.OutboundSseEvent;
|
|
||||||
import jakarta.ws.rs.sse.Sse;
|
|
||||||
import jakarta.ws.rs.sse.SseEventSink;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ScheduledExecutorService;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
|
||||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
|
|
||||||
* numbers change, so it neither polls nor reloads.
|
|
||||||
*
|
|
||||||
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
|
|
||||||
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
|
|
||||||
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
|
|
||||||
* connection, which bounds what a browser accumulates on one.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class ApprovalCountsStreamService {
|
|
||||||
|
|
||||||
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
|
|
||||||
private static final long CHECK_MILLIS = 1_000L;
|
|
||||||
|
|
||||||
private static final long PING_MILLIS = 15_000L;
|
|
||||||
|
|
||||||
private static final long LIFETIME_MILLIS = 300_000L;
|
|
||||||
|
|
||||||
/** A screen listening, and the version of the numbers it was last sent. */
|
|
||||||
private static final class Listener {
|
|
||||||
|
|
||||||
private final ApprovalCountsPlan plan;
|
|
||||||
private final SseEventSink sink;
|
|
||||||
private final Sse sse;
|
|
||||||
private final long expiresAt;
|
|
||||||
private volatile String version;
|
|
||||||
private volatile long lastSent;
|
|
||||||
|
|
||||||
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
|
|
||||||
this.plan = plan;
|
|
||||||
this.sink = sink;
|
|
||||||
this.sse = sse;
|
|
||||||
this.expiresAt = expiresAt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private final ApprovalCountsReadPlatformService readService;
|
|
||||||
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
|
|
||||||
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
|
||||||
final Thread thread = new Thread(runnable, "approval-counts-stream");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
});
|
|
||||||
|
|
||||||
@PostConstruct
|
|
||||||
void startTicker() {
|
|
||||||
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
|
|
||||||
}
|
|
||||||
|
|
||||||
@PreDestroy
|
|
||||||
void stopTicker() {
|
|
||||||
this.ticker.shutdownNow();
|
|
||||||
this.listeners.forEach(this::close);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
|
|
||||||
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
|
|
||||||
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
|
|
||||||
try {
|
|
||||||
publish(listener, this.readService.retrieve(plan));
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
sink.close();
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
this.listeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
|
|
||||||
private void tick() {
|
|
||||||
if (this.listeners.isEmpty()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final long now = System.currentTimeMillis();
|
|
||||||
final Map<String, ApprovalCountsData> computed = new HashMap<>();
|
|
||||||
for (final Listener listener : this.listeners) {
|
|
||||||
try {
|
|
||||||
if (listener.sink.isClosed() || now >= listener.expiresAt) {
|
|
||||||
close(listener);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
ApprovalCountsData data = computed.get(listener.plan.group());
|
|
||||||
if (data == null) {
|
|
||||||
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
|
|
||||||
data = this.readService.retrieve(listener.plan);
|
|
||||||
computed.put(listener.plan.group(), data);
|
|
||||||
}
|
|
||||||
if (!data.version().equals(listener.version)) {
|
|
||||||
publish(listener, data);
|
|
||||||
} else if (now - listener.lastSent >= PING_MILLIS) {
|
|
||||||
listener.lastSent = now;
|
|
||||||
send(listener, listener.sse.newEventBuilder().comment("ping").build());
|
|
||||||
}
|
|
||||||
} catch (final RuntimeException e) {
|
|
||||||
close(listener);
|
|
||||||
} finally {
|
|
||||||
ThreadLocalContextUtil.clearTenant();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void publish(final Listener listener, final ApprovalCountsData data) {
|
|
||||||
listener.version = data.version();
|
|
||||||
listener.lastSent = System.currentTimeMillis();
|
|
||||||
send(listener, listener.sse.newEvent("counts", data.toJson()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private void send(final Listener listener, final OutboundSseEvent event) {
|
|
||||||
listener.sink.send(event).whenComplete((sent, failure) -> {
|
|
||||||
if (failure != null) {
|
|
||||||
close(listener);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void close(final Listener listener) {
|
|
||||||
this.listeners.remove(listener);
|
|
||||||
try {
|
|
||||||
listener.sink.close();
|
|
||||||
} catch (final RuntimeException ignored) {
|
|
||||||
// The screen is already gone.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+319
@@ -0,0 +1,319 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.api;
|
||||||
|
|
||||||
|
import com.google.gson.Gson;
|
||||||
|
import com.google.gson.GsonBuilder;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.DefaultValue;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.PUT;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.QueryParam;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||||
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicAlertService;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicCatalog;
|
||||||
|
import org.apache.fineract.portfolio.cic.service.CicConfigService;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
|
||||||
|
* the bank's customers is enquired by another lender.
|
||||||
|
*
|
||||||
|
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
|
||||||
|
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
|
||||||
|
*/
|
||||||
|
@Path("/v1/cic")
|
||||||
|
@Component
|
||||||
|
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicApiResource {
|
||||||
|
|
||||||
|
private final PlatformSecurityContext context;
|
||||||
|
private final CicConfigService configService;
|
||||||
|
private final CicAlertService alertService;
|
||||||
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
|
// ── Catalog ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("catalog")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String catalog() {
|
||||||
|
context.authenticatedUser();
|
||||||
|
final Map<String, Object> c = new LinkedHashMap<>();
|
||||||
|
c.put("environments", CicCatalog.ENVIRONMENTS);
|
||||||
|
c.put("authTypes", CicCatalog.AUTH_TYPES);
|
||||||
|
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
|
||||||
|
c.put("httpMethods", CicCatalog.HTTP_METHODS);
|
||||||
|
c.put("cbsFields", CicCatalog.CBS_FIELDS);
|
||||||
|
c.put("resultFields", CicCatalog.RESULT_FIELDS);
|
||||||
|
c.put("rootFields", CicCatalog.ROOT_FIELDS);
|
||||||
|
c.put("transforms", CicCatalog.TRANSFORMS);
|
||||||
|
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
|
||||||
|
c.put("matchTypes", CicCatalog.MATCH_TYPES);
|
||||||
|
return gson.toJson(c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
|
||||||
|
requireAny("READ_CICBUREAU", "READ_CICALERT");
|
||||||
|
return gson.toJson(configService.listBureaus(whitelistedOnly));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String bureau(@PathParam("id") final Long id) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(configService.getBureau(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String updateBureau(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.updateBureau(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}/whitelist")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String whitelist(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
final JsonObject in = json(body);
|
||||||
|
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
|
||||||
|
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/test-connection")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String testConnection(@PathParam("id") final Long id) {
|
||||||
|
require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.testConnection(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── API mapping ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("bureaus/{id}/endpoints/{type}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(configService.getEndpoint(id, type));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("bureaus/{id}/endpoints/{type}")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICBUREAU");
|
||||||
|
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/preview")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String preview(@PathParam("id") final Long id, final String body) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.preview(id, json(body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/parse-test")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String parseTest(@PathParam("id") final Long id, final String body) {
|
||||||
|
require("READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.parseTest(id, json(body)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Fetch / import ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/fetch")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetch(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("FETCH_CICALERT");
|
||||||
|
final JsonObject in = json(body);
|
||||||
|
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("bureaus/{id}/import")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String importFile(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("FETCH_CICALERT");
|
||||||
|
return gson.toJson(alertService.importFile(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("fetch-runs")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
|
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||||
|
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("fetch-runs/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String fetchRun(@PathParam("id") final Long id) {
|
||||||
|
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||||
|
return gson.toJson(alertService.fetchRun(id, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("dashboard")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
|
||||||
|
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
|
||||||
|
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
|
||||||
|
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
|
||||||
|
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
|
||||||
|
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||||
|
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
final JsonObject f = new JsonObject();
|
||||||
|
f.addProperty("status", status);
|
||||||
|
f.addProperty("match", match);
|
||||||
|
f.addProperty("clientId", clientId);
|
||||||
|
f.addProperty("bureauId", bureauId);
|
||||||
|
f.addProperty("officeId", officeId);
|
||||||
|
f.addProperty("loanType", loanType);
|
||||||
|
f.addProperty("institutionType", institutionType);
|
||||||
|
f.addProperty("fromDate", fromDate);
|
||||||
|
f.addProperty("toDate", toDate);
|
||||||
|
f.addProperty("minAmount", minAmount);
|
||||||
|
f.addProperty("search", search);
|
||||||
|
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts/options")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alertOptions() {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.filterOptions());
|
||||||
|
}
|
||||||
|
|
||||||
|
@GET
|
||||||
|
@Path("alerts/{id}")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String alert(@PathParam("id") final Long id) {
|
||||||
|
require("READ_CICALERT");
|
||||||
|
return gson.toJson(alertService.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("alerts/{id}/status")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String updateStatus(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.updateStatus(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PUT
|
||||||
|
@Path("alerts/{id}/client")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String linkClient(@PathParam("id") final Long id, final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.linkClient(id, json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("alerts/mark-seen")
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public String markSeen(final String body) {
|
||||||
|
final AppUser user = require("UPDATE_CICALERT");
|
||||||
|
return gson.toJson(alertService.markSeen(json(body), user));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private AppUser require(final String permission) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
user.validateHasPermissionTo(permission);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppUser requireAny(final String... permissions) {
|
||||||
|
final AppUser user = context.authenticatedUser();
|
||||||
|
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject json(final String body) {
|
||||||
|
if (StringUtils.isBlank(body)) {
|
||||||
|
return new JsonObject();
|
||||||
|
}
|
||||||
|
final JsonElement e = JsonParser.parseString(body);
|
||||||
|
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String text(final JsonObject in, final String key) {
|
||||||
|
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int clamp(final int limit) {
|
||||||
|
return Math.max(1, Math.min(200, limit));
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
|
||||||
|
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.ActionContext;
|
||||||
|
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
|
||||||
|
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicAlertScheduler {
|
||||||
|
|
||||||
|
private final FineractProperties fineractProperties;
|
||||||
|
private final TenantDetailsService tenantDetailsService;
|
||||||
|
private final BusinessDateReadPlatformService businessDateReadPlatformService;
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final CicAlertService alertService;
|
||||||
|
|
||||||
|
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
|
||||||
|
public void fetchDueAlerts() {
|
||||||
|
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||||
|
try {
|
||||||
|
ThreadLocalContextUtil.setTenant(tenant);
|
||||||
|
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
|
||||||
|
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
|
||||||
|
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||||
|
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
|
||||||
|
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
|
||||||
|
""");
|
||||||
|
final long now = Instant.now().toEpochMilli();
|
||||||
|
for (final Map<String, Object> b : due) {
|
||||||
|
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
|
||||||
|
final Object last = b.get("last_fetch_on");
|
||||||
|
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
|
||||||
|
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
|
||||||
|
e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
// A tenant whose database does not have the CIC tables yet.
|
||||||
|
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
|
||||||
|
} finally {
|
||||||
|
ThreadLocalContextUtil.reset();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1041
File diff suppressed because it is too large
Load Diff
+215
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonPrimitive;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.math.RoundingMode;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
|
||||||
|
* value is written into the bureau request or read back from the bureau response.
|
||||||
|
*/
|
||||||
|
public final class CicCatalog {
|
||||||
|
|
||||||
|
private CicCatalog() {}
|
||||||
|
|
||||||
|
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
|
||||||
|
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
|
||||||
|
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
|
||||||
|
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
|
||||||
|
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
|
||||||
|
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
|
||||||
|
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
|
||||||
|
|
||||||
|
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
|
||||||
|
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
|
||||||
|
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
|
||||||
|
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
|
||||||
|
CBS_FIELDS.put("fetch.date", "Request date (business date)");
|
||||||
|
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
|
||||||
|
CBS_FIELDS.put("bureau.username", "Bureau user id");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
|
||||||
|
* response; "alerts" points at the list, and every other field is read from each item of that list.
|
||||||
|
*/
|
||||||
|
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
|
||||||
|
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
|
||||||
|
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
|
||||||
|
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
|
||||||
|
RESULT_FIELDS.put("pan", "Customer PAN");
|
||||||
|
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
|
||||||
|
RESULT_FIELDS.put("customerName", "Customer name");
|
||||||
|
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
|
||||||
|
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
|
||||||
|
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
|
||||||
|
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
|
||||||
|
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
|
||||||
|
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
|
||||||
|
RESULT_FIELDS.put("loanType", "Loan type applied for");
|
||||||
|
RESULT_FIELDS.put("amount", "Loan amount applied for");
|
||||||
|
RESULT_FIELDS.put("purpose", "Enquiry purpose");
|
||||||
|
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result fields read from the root of the response, not from each alert. */
|
||||||
|
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
|
||||||
|
|
||||||
|
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
|
||||||
|
static {
|
||||||
|
TRANSFORMS.put("NONE", "As is");
|
||||||
|
TRANSFORMS.put("UPPER", "UPPER CASE");
|
||||||
|
TRANSFORMS.put("LOWER", "lower case");
|
||||||
|
TRANSFORMS.put("TRIM", "Trim spaces");
|
||||||
|
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
|
||||||
|
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
|
||||||
|
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
|
||||||
|
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
|
||||||
|
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
|
||||||
|
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
|
||||||
|
TRANSFORMS.put("NUMBER", "Number");
|
||||||
|
TRANSFORMS.put("BOOLEAN", "true / false");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
|
||||||
|
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
|
||||||
|
|
||||||
|
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||||
|
|
||||||
|
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
|
||||||
|
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
|
||||||
|
if (raw == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||||
|
final String s = String.valueOf(raw).trim();
|
||||||
|
if (s.isEmpty()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (DATE_PATTERNS.containsKey(t)) {
|
||||||
|
try {
|
||||||
|
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
|
||||||
|
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return new JsonPrimitive(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch (t) {
|
||||||
|
case "UPPER":
|
||||||
|
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||||
|
case "LOWER":
|
||||||
|
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||||
|
case "TRIM":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||||
|
case "DIGITS_ONLY": {
|
||||||
|
final String digits = s.replaceAll("\\D", "");
|
||||||
|
return digits.isEmpty() ? null : new JsonPrimitive(digits);
|
||||||
|
}
|
||||||
|
case "AMOUNT_INTEGER":
|
||||||
|
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
|
||||||
|
case "NUMBER":
|
||||||
|
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
|
||||||
|
case "BOOLEAN":
|
||||||
|
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
|
||||||
|
default:
|
||||||
|
return new JsonPrimitive(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
|
||||||
|
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
|
||||||
|
*/
|
||||||
|
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
|
||||||
|
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||||
|
final String s = value.getAsString().trim();
|
||||||
|
try {
|
||||||
|
if (DATE_PATTERNS.containsKey(t)) {
|
||||||
|
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
|
||||||
|
}
|
||||||
|
switch (t) {
|
||||||
|
case "NUMBER":
|
||||||
|
case "AMOUNT_INTEGER": {
|
||||||
|
final String numeric = s.replaceAll("[^0-9.\\-]", "");
|
||||||
|
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
|
||||||
|
}
|
||||||
|
case "BOOLEAN":
|
||||||
|
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
|
||||||
|
case "UPPER":
|
||||||
|
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||||
|
case "LOWER":
|
||||||
|
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||||
|
case "TRIM":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||||
|
case "DIGITS_ONLY":
|
||||||
|
return new JsonPrimitive(s.replaceAll("\\D", ""));
|
||||||
|
default:
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parses a bureau date in the given pattern, falling back to ISO. */
|
||||||
|
public static LocalDate parseDate(final String s, final String pattern) {
|
||||||
|
final String v = s.trim();
|
||||||
|
try {
|
||||||
|
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
|
||||||
|
public static String mask(final String value) {
|
||||||
|
if (value == null || value.length() <= 4) {
|
||||||
|
return value == null ? null : "****";
|
||||||
|
}
|
||||||
|
final int keepStart = Math.min(2, value.length() / 4);
|
||||||
|
final int keepEnd = Math.min(4, value.length() / 3);
|
||||||
|
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
|
||||||
|
public static String maskAadhaar(final String value) {
|
||||||
|
if (value == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String digits = value.replaceAll("\\D", "");
|
||||||
|
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
|
||||||
|
}
|
||||||
|
}
|
||||||
+463
@@ -0,0 +1,463 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.useradministration.domain.AppUser;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
|
||||||
|
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicConfigService {
|
||||||
|
|
||||||
|
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
private final CicHttpGateway gateway;
|
||||||
|
|
||||||
|
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Full row including the encrypted secret - internal use only, never returned by the API. */
|
||||||
|
public Map<String, Object> bureauRow(final Long id) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
|
||||||
|
if (rows.isEmpty()) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
|
||||||
|
}
|
||||||
|
return rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> row : jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
|
||||||
|
out.add(publicView(row));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getBureau(final Long id) {
|
||||||
|
return publicView(bureauRow(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
|
||||||
|
private Map<String, Object> publicView(final Map<String, Object> row) {
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
final Long id = ((Number) row.get("id")).longValue();
|
||||||
|
v.put("id", id);
|
||||||
|
v.put("code", row.get("code"));
|
||||||
|
v.put("name", row.get("name"));
|
||||||
|
v.put("description", row.get("description"));
|
||||||
|
v.put("whitelisted", row.get("whitelisted"));
|
||||||
|
v.put("isDefault", row.get("is_default"));
|
||||||
|
v.put("environment", row.get("environment"));
|
||||||
|
v.put("baseUrl", row.get("base_url"));
|
||||||
|
v.put("memberCode", row.get("member_code"));
|
||||||
|
v.put("authType", row.get("auth_type"));
|
||||||
|
v.put("username", row.get("username"));
|
||||||
|
v.put("apiKeyHeader", row.get("api_key_header"));
|
||||||
|
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
|
||||||
|
v.put("tokenUrl", row.get("token_url"));
|
||||||
|
v.put("clientId", row.get("client_id"));
|
||||||
|
v.put("timeoutMs", row.get("timeout_ms"));
|
||||||
|
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
|
||||||
|
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
|
||||||
|
v.put("alertLookbackDays", row.get("alert_lookback_days"));
|
||||||
|
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
|
||||||
|
v.put("lastFetchStatus", row.get("last_fetch_status"));
|
||||||
|
v.put("lastFetchMessage", row.get("last_fetch_message"));
|
||||||
|
v.put("lastTestStatus", row.get("last_test_status"));
|
||||||
|
v.put("lastTestMessage", row.get("last_test_message"));
|
||||||
|
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
|
||||||
|
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
|
||||||
|
v.put("activeEndpoints", jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
|
||||||
|
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
|
||||||
|
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
|
||||||
|
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
|
||||||
|
Integer.class, id));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
final String environment = upper(text(in, "environment", (String) row.get("environment")));
|
||||||
|
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
|
||||||
|
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
|
||||||
|
}
|
||||||
|
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
|
||||||
|
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
|
||||||
|
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
|
||||||
|
}
|
||||||
|
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
|
||||||
|
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
|
||||||
|
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
|
||||||
|
}
|
||||||
|
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
|
||||||
|
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
|
||||||
|
}
|
||||||
|
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
|
||||||
|
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
|
||||||
|
if (timeoutMs < 1000 || timeoutMs > 300000) {
|
||||||
|
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
|
||||||
|
}
|
||||||
|
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
|
||||||
|
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
|
||||||
|
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
|
||||||
|
if (interval < 15 || interval > 1440) {
|
||||||
|
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
|
||||||
|
}
|
||||||
|
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
|
||||||
|
if (lookback < 1 || lookback > 90) {
|
||||||
|
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String secretEnc = (String) row.get("secret_enc");
|
||||||
|
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
|
||||||
|
secretEnc = null;
|
||||||
|
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
|
||||||
|
secretEnc = gateway.encrypt(in.get("secret").getAsString());
|
||||||
|
}
|
||||||
|
|
||||||
|
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
|
||||||
|
if (makeDefault) {
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
|
||||||
|
}
|
||||||
|
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
|
||||||
|
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
|
||||||
|
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
|
||||||
|
updated_on = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
|
||||||
|
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
|
||||||
|
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
|
||||||
|
trimToNull(text(in, "username", (String) row.get("username"))),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
|
||||||
|
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
|
||||||
|
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
|
||||||
|
gateway.evictToken(id);
|
||||||
|
|
||||||
|
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
|
||||||
|
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
|
||||||
|
assertReadyForLive(bureauRow(id));
|
||||||
|
}
|
||||||
|
return getBureau(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
if (whitelisted) {
|
||||||
|
assertReadyForLive(row);
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
|
||||||
|
user.getId(), Timestamp.from(Instant.now()), id);
|
||||||
|
return getBureau(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What a bureau needs before alerts may be fetched from it. */
|
||||||
|
public void assertReadyForLive(final Map<String, Object> row) {
|
||||||
|
final String code = (String) row.get("code");
|
||||||
|
final Integer endpoints = jdbcTemplate.queryForObject(
|
||||||
|
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
|
||||||
|
if (endpoints == null || endpoints == 0) {
|
||||||
|
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
|
||||||
|
}
|
||||||
|
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||||
|
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
|
||||||
|
}
|
||||||
|
final String auth = (String) row.get("auth_type");
|
||||||
|
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
|
||||||
|
if (!"NONE".equals(auth) && !hasSecret) {
|
||||||
|
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
|
||||||
|
}
|
||||||
|
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
|
||||||
|
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
|
||||||
|
}
|
||||||
|
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
|
||||||
|
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
|
||||||
|
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
|
||||||
|
public Map<String, Object> testConnection(final Long id) {
|
||||||
|
final Map<String, Object> row = bureauRow(id);
|
||||||
|
String status;
|
||||||
|
String message;
|
||||||
|
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||||
|
status = "OK";
|
||||||
|
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
|
||||||
|
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "No base URL configured.";
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
|
||||||
|
gateway.evictToken(id);
|
||||||
|
gateway.oauthToken(row);
|
||||||
|
}
|
||||||
|
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
|
||||||
|
if (r.status() == 0) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "Not reachable: " + r.error();
|
||||||
|
} else if (r.status() == 401 || r.status() == 403) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
|
||||||
|
} else {
|
||||||
|
status = "OK";
|
||||||
|
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
|
||||||
|
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
|
||||||
|
}
|
||||||
|
} catch (final IOException | RuntimeException e) {
|
||||||
|
status = "FAILED";
|
||||||
|
message = e.getMessage();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
|
||||||
|
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
|
||||||
|
final Map<String, Object> res = new LinkedHashMap<>();
|
||||||
|
res.put("status", status);
|
||||||
|
res.put("message", message);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Endpoints and mappings ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
|
||||||
|
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
|
||||||
|
return rows.isEmpty() ? null : rows.get(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
|
||||||
|
return jdbcTemplate.queryForList(
|
||||||
|
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
|
||||||
|
bureauRow(bureauId);
|
||||||
|
final String t = upper(type);
|
||||||
|
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||||
|
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||||
|
}
|
||||||
|
final Map<String, Object> e = endpointRow(bureauId, t);
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
v.put("bureauId", bureauId);
|
||||||
|
v.put("endpointType", t);
|
||||||
|
v.put("exists", e != null);
|
||||||
|
v.put("id", e == null ? null : e.get("id"));
|
||||||
|
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
|
||||||
|
v.put("path", e == null ? null : e.get("path"));
|
||||||
|
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
|
||||||
|
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
|
||||||
|
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
|
||||||
|
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
|
||||||
|
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
|
||||||
|
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
|
||||||
|
final List<Map<String, Object>> out = new ArrayList<>();
|
||||||
|
for (final Map<String, Object> m : rows) {
|
||||||
|
final Map<String, Object> v = new LinkedHashMap<>();
|
||||||
|
v.put("id", m.get("id"));
|
||||||
|
v.put("sourceField", m.get("source_field"));
|
||||||
|
v.put("targetField", m.get("target_field"));
|
||||||
|
v.put("transform", m.get("transform"));
|
||||||
|
v.put("defaultValue", m.get("default_value"));
|
||||||
|
v.put("mandatory", m.get("is_mandatory"));
|
||||||
|
v.put("sortOrder", m.get("sort_order"));
|
||||||
|
out.add(v);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
|
||||||
|
@Transactional
|
||||||
|
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
|
||||||
|
final Map<String, Object> bureau = bureauRow(bureauId);
|
||||||
|
final String t = upper(type);
|
||||||
|
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||||
|
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||||
|
}
|
||||||
|
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
|
||||||
|
if (!CicCatalog.HTTP_METHODS.contains(method)) {
|
||||||
|
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
|
||||||
|
}
|
||||||
|
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
|
||||||
|
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
|
||||||
|
requireJsonObject(headersJson, "Headers");
|
||||||
|
requireJsonObject(baseBody, "Base request body");
|
||||||
|
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
|
||||||
|
if (!h.getValue().isJsonPrimitive()) {
|
||||||
|
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
|
||||||
|
|
||||||
|
final List<Object[]> rows = new ArrayList<>();
|
||||||
|
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
|
||||||
|
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
|
||||||
|
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
|
||||||
|
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
|
||||||
|
}
|
||||||
|
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
|
||||||
|
throw fail("error.msg.cic.no.alert.list",
|
||||||
|
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> existing = endpointRow(bureauId, t);
|
||||||
|
final Timestamp now = Timestamp.from(Instant.now());
|
||||||
|
if (existing == null) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
|
||||||
|
is_active, updated_by, updated_on)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", bureauId, t, method, trimToNull(text(in, "path", null)),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||||
|
user.getId(), now);
|
||||||
|
existing = endpointRow(bureauId, t);
|
||||||
|
} else {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
|
||||||
|
updated_by = ?, updated_on = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""", method, trimToNull(text(in, "path", null)),
|
||||||
|
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||||
|
user.getId(), now, existing.get("id"));
|
||||||
|
}
|
||||||
|
final Long endpointId = ((Number) existing.get("id")).longValue();
|
||||||
|
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
|
||||||
|
for (final Object[] r : rows) {
|
||||||
|
jdbcTemplate.update("""
|
||||||
|
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
|
||||||
|
sort_order)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
|
||||||
|
}
|
||||||
|
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
|
||||||
|
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
|
||||||
|
assertReadyForLive(bureauRow(bureauId));
|
||||||
|
}
|
||||||
|
return getEndpoint(bureauId, t);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
|
||||||
|
int order = 0;
|
||||||
|
for (final JsonElement el : array) {
|
||||||
|
final JsonObject m = el.getAsJsonObject();
|
||||||
|
order += 10;
|
||||||
|
final String source = trimToNull(text(m, "sourceField", null));
|
||||||
|
final String target = trimToNull(text(m, "targetField", null));
|
||||||
|
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
|
||||||
|
final String defaultValue = trimToNull(text(m, "defaultValue", null));
|
||||||
|
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
|
||||||
|
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
|
||||||
|
final String label = direction + " row " + (order / 10);
|
||||||
|
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
|
||||||
|
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
|
||||||
|
}
|
||||||
|
if (target == null) {
|
||||||
|
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
|
||||||
|
}
|
||||||
|
if ("REQUEST".equals(direction)) {
|
||||||
|
if (source == null && defaultValue == null) {
|
||||||
|
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
|
||||||
|
}
|
||||||
|
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
|
||||||
|
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
|
||||||
|
}
|
||||||
|
if (!CicJsonPath.isValid(target)) {
|
||||||
|
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (source == null || !CicJsonPath.isValid(source)) {
|
||||||
|
throw fail("error.msg.cic.mapping.path.invalid",
|
||||||
|
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
|
||||||
|
}
|
||||||
|
if (!RESULT_KEY.matcher(target).matches()) {
|
||||||
|
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private static void requireJsonObject(final String text, final String label) {
|
||||||
|
try {
|
||||||
|
if (!JsonParser.parseString(text).isJsonObject()) {
|
||||||
|
throw new IllegalArgumentException();
|
||||||
|
}
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static String text(final JsonObject in, final String key, final String fallback) {
|
||||||
|
if (in == null || !in.has(key)) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int intOf(final JsonObject in, final String key, final int fallback) {
|
||||||
|
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String upper(final String s) {
|
||||||
|
return s == null ? null : s.trim().toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String trimToNull(final String s) {
|
||||||
|
return StringUtils.trimToNull(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
|
||||||
|
return new GeneralPlatformDomainRuleException(code, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
+194
@@ -0,0 +1,194 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import com.google.gson.JsonParser;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import okhttp3.FormBody;
|
||||||
|
import okhttp3.MediaType;
|
||||||
|
import okhttp3.OkHttpClient;
|
||||||
|
import okhttp3.Request;
|
||||||
|
import okhttp3.RequestBody;
|
||||||
|
import okhttp3.Response;
|
||||||
|
import okhttp3.ResponseBody;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
|
||||||
|
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
|
||||||
|
* here, for the call itself.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicHttpGateway {
|
||||||
|
|
||||||
|
private static final int MAX_BODY_CHARS = 2_000_000;
|
||||||
|
|
||||||
|
private final DatabasePasswordEncryptor encryptor;
|
||||||
|
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
private record CachedToken(String token, Instant expiresAt) {}
|
||||||
|
|
||||||
|
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
|
||||||
|
public record HttpResult(int status, String body, long durationMs, String error) {
|
||||||
|
|
||||||
|
public boolean ok() {
|
||||||
|
return status >= 200 && status < 300;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public String encrypt(final String plain) {
|
||||||
|
return plain == null ? null : encryptor.encrypt(plain);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String secretOf(final Map<String, Object> bureau) {
|
||||||
|
final Object enc = bureau.get("secret_enc");
|
||||||
|
if (enc == null || enc.toString().isBlank()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return encryptor.decrypt(enc.toString());
|
||||||
|
} catch (final RuntimeException e) {
|
||||||
|
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
|
||||||
|
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
|
||||||
|
+ " Re-enter it on the bureau configuration.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String joinUrl(final String baseUrl, final String path) {
|
||||||
|
if (StringUtils.startsWithAny(path, "http://", "https://")) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
|
||||||
|
if (StringUtils.isBlank(path)) {
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
return base + (path.startsWith("/") ? path : "/" + path);
|
||||||
|
}
|
||||||
|
|
||||||
|
private OkHttpClient client(final Map<String, Object> bureau) {
|
||||||
|
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
|
||||||
|
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||||
|
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends one request to the bureau with its configured authentication. */
|
||||||
|
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
|
||||||
|
final String contentType, final String body) {
|
||||||
|
final long started = System.currentTimeMillis();
|
||||||
|
try {
|
||||||
|
final Request.Builder rb = new Request.Builder().url(url);
|
||||||
|
headers.forEach((k, v) -> {
|
||||||
|
if (StringUtils.isNotBlank(k) && v != null) {
|
||||||
|
rb.header(k, v);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
applyAuth(bureau, rb);
|
||||||
|
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
|
||||||
|
if ("GET".equals(m)) {
|
||||||
|
rb.get();
|
||||||
|
} else {
|
||||||
|
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
|
||||||
|
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
|
||||||
|
}
|
||||||
|
try (Response response = client(bureau).newCall(rb.build()).execute()) {
|
||||||
|
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
|
||||||
|
}
|
||||||
|
} catch (final IOException | IllegalArgumentException e) {
|
||||||
|
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
|
||||||
|
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String read(final ResponseBody body) throws IOException {
|
||||||
|
if (body == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
|
||||||
|
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
|
||||||
|
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
|
||||||
|
final String username = (String) bureau.get("username");
|
||||||
|
switch (auth) {
|
||||||
|
case "BASIC" -> {
|
||||||
|
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
|
||||||
|
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
|
||||||
|
}
|
||||||
|
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
|
||||||
|
StringUtils.defaultString(secretOf(bureau)));
|
||||||
|
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
|
||||||
|
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
|
||||||
|
default -> {
|
||||||
|
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** OAuth2 client-credentials token, cached until a minute before it expires. */
|
||||||
|
public String oauthToken(final Map<String, Object> bureau) throws IOException {
|
||||||
|
final Long id = ((Number) bureau.get("id")).longValue();
|
||||||
|
final CachedToken cached = tokens.get(id);
|
||||||
|
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
|
||||||
|
return cached.token();
|
||||||
|
}
|
||||||
|
final String tokenUrl = (String) bureau.get("token_url");
|
||||||
|
if (StringUtils.isBlank(tokenUrl)) {
|
||||||
|
throw new IOException("OAuth2 token URL is not configured");
|
||||||
|
}
|
||||||
|
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
|
||||||
|
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
|
||||||
|
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
|
||||||
|
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
|
||||||
|
final String text = read(response.body());
|
||||||
|
if (!response.isSuccessful()) {
|
||||||
|
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
|
||||||
|
}
|
||||||
|
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
|
||||||
|
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
|
||||||
|
if (!obj.has("access_token")) {
|
||||||
|
throw new IOException("Token endpoint response has no access_token");
|
||||||
|
}
|
||||||
|
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
|
||||||
|
final String token = obj.get("access_token").getAsString();
|
||||||
|
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Forget a cached token (after the bureau's credentials change). */
|
||||||
|
public void evictToken(final Long bureauId) {
|
||||||
|
tokens.remove(bureauId);
|
||||||
|
}
|
||||||
|
}
|
||||||
+133
@@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonElement;
|
||||||
|
import com.google.gson.JsonNull;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.regex.Matcher;
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
|
||||||
|
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
|
||||||
|
* way; reading returns null for anything absent.
|
||||||
|
*/
|
||||||
|
public final class CicJsonPath {
|
||||||
|
|
||||||
|
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
|
||||||
|
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
|
||||||
|
|
||||||
|
private CicJsonPath() {}
|
||||||
|
|
||||||
|
/** One step of a path: an object key, optionally followed by array indexes. */
|
||||||
|
private record Step(String key, List<Integer> indexes) {}
|
||||||
|
|
||||||
|
private static List<Step> parse(final String path) {
|
||||||
|
if (path == null || path.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Empty JSON path");
|
||||||
|
}
|
||||||
|
final List<Step> steps = new ArrayList<>();
|
||||||
|
for (final String raw : path.trim().split("\\.")) {
|
||||||
|
final Matcher m = SEGMENT.matcher(raw);
|
||||||
|
if (!m.matches()) {
|
||||||
|
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
|
||||||
|
}
|
||||||
|
final List<Integer> indexes = new ArrayList<>();
|
||||||
|
final Matcher im = INDEX.matcher(m.group(2));
|
||||||
|
while (im.find()) {
|
||||||
|
indexes.add(Integer.parseInt(im.group(1)));
|
||||||
|
}
|
||||||
|
steps.add(new Step(m.group(1), indexes));
|
||||||
|
}
|
||||||
|
return steps;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether the path is syntactically valid. */
|
||||||
|
public static boolean isValid(final String path) {
|
||||||
|
try {
|
||||||
|
parse(path);
|
||||||
|
return true;
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static JsonElement get(final JsonElement root, final String path) {
|
||||||
|
JsonElement current = root;
|
||||||
|
for (final Step step : parse(path)) {
|
||||||
|
if (!step.key().isEmpty()) {
|
||||||
|
if (current == null || !current.isJsonObject()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = current.getAsJsonObject().get(step.key());
|
||||||
|
}
|
||||||
|
for (final Integer index : step.indexes()) {
|
||||||
|
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
current = current.getAsJsonArray().get(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current == null || current.isJsonNull() ? null : current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void set(final JsonObject root, final String path, final JsonElement value) {
|
||||||
|
final List<Step> steps = parse(path);
|
||||||
|
JsonElement current = root;
|
||||||
|
for (int s = 0; s < steps.size(); s++) {
|
||||||
|
final Step step = steps.get(s);
|
||||||
|
final boolean lastStep = s == steps.size() - 1;
|
||||||
|
if (!step.key().isEmpty()) {
|
||||||
|
final JsonObject obj = current.getAsJsonObject();
|
||||||
|
if (lastStep && step.indexes().isEmpty()) {
|
||||||
|
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonElement next = obj.get(step.key());
|
||||||
|
if (next == null || next.isJsonNull()) {
|
||||||
|
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
|
||||||
|
obj.add(step.key(), next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
for (int i = 0; i < step.indexes().size(); i++) {
|
||||||
|
final int index = step.indexes().get(i);
|
||||||
|
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
|
||||||
|
final JsonArray array = current.getAsJsonArray();
|
||||||
|
while (array.size() <= index) {
|
||||||
|
array.add(JsonNull.INSTANCE);
|
||||||
|
}
|
||||||
|
if (lastIndex) {
|
||||||
|
array.set(index, value == null ? JsonNull.INSTANCE : value);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JsonElement next = array.get(index);
|
||||||
|
if (next == null || next.isJsonNull()) {
|
||||||
|
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
|
||||||
|
array.set(index, next);
|
||||||
|
}
|
||||||
|
current = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+153
@@ -0,0 +1,153 @@
|
|||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
package org.apache.fineract.portfolio.cic.service;
|
||||||
|
|
||||||
|
import com.google.gson.JsonArray;
|
||||||
|
import com.google.gson.JsonObject;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Random;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
|
||||||
|
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
|
||||||
|
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
|
||||||
|
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
|
||||||
|
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CicSimulator {
|
||||||
|
|
||||||
|
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
|
||||||
|
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
|
||||||
|
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
|
||||||
|
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
|
||||||
|
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
|
||||||
|
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
|
||||||
|
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
|
||||||
|
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
|
||||||
|
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
|
||||||
|
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
|
||||||
|
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
|
||||||
|
|
||||||
|
private final JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
|
||||||
|
final String code = String.valueOf(bureau.get("code"));
|
||||||
|
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
|
||||||
|
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
|
||||||
|
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
|
||||||
|
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
|
||||||
|
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||||
|
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
|
||||||
|
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
|
||||||
|
FROM m_client c
|
||||||
|
WHERE c.status_enum = 300
|
||||||
|
ORDER BY c.id
|
||||||
|
LIMIT 300
|
||||||
|
""");
|
||||||
|
|
||||||
|
final JsonArray alerts = new JsonArray();
|
||||||
|
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
|
||||||
|
for (int d = 0; d <= days; d++) {
|
||||||
|
final LocalDate day = from.plusDays(d);
|
||||||
|
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
|
||||||
|
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
|
||||||
|
int seq = 0;
|
||||||
|
for (int i = 0; i < count; i++) {
|
||||||
|
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
|
||||||
|
}
|
||||||
|
if (r.nextInt(8) == 0) {
|
||||||
|
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
|
||||||
|
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
|
||||||
|
r, null));
|
||||||
|
}
|
||||||
|
if (!clients.isEmpty() && r.nextInt(10) == 0) {
|
||||||
|
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
|
||||||
|
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final JsonObject header = new JsonObject();
|
||||||
|
header.addProperty("status", "SUCCESS");
|
||||||
|
header.addProperty("referenceNumber", reference);
|
||||||
|
header.addProperty("bureau", code);
|
||||||
|
header.addProperty("fromDate", from.format(DDMMYYYY));
|
||||||
|
header.addProperty("toDate", to.format(DDMMYYYY));
|
||||||
|
final JsonObject data = new JsonObject();
|
||||||
|
data.addProperty("totalAlerts", alerts.size());
|
||||||
|
data.add("alerts", alerts);
|
||||||
|
final JsonObject root = new JsonObject();
|
||||||
|
root.add("responseHeader", header);
|
||||||
|
root.add("data", data);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
|
||||||
|
final String forcedMemberCode) {
|
||||||
|
final JsonObject consumer = new JsonObject();
|
||||||
|
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
|
||||||
|
final Object pan = client.get("pan");
|
||||||
|
final Object aadhaar = client.get("aadhaar");
|
||||||
|
if (pan != null && !pan.toString().isBlank()) {
|
||||||
|
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
|
||||||
|
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
|
||||||
|
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
|
||||||
|
} else if (client.get("account_no") != null) {
|
||||||
|
consumer.addProperty("memberReference", client.get("account_no").toString());
|
||||||
|
}
|
||||||
|
if (client.get("date_of_birth") != null) {
|
||||||
|
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
|
||||||
|
}
|
||||||
|
|
||||||
|
final int lenderIdx = r.nextInt(LENDERS.length);
|
||||||
|
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
|
||||||
|
final int min = (Integer) type[1];
|
||||||
|
final int max = (Integer) type[2];
|
||||||
|
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
|
||||||
|
|
||||||
|
final JsonObject enquiry = new JsonObject();
|
||||||
|
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
|
||||||
|
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
|
||||||
|
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
|
||||||
|
enquiry.addProperty("date", day.format(DDMMYYYY));
|
||||||
|
enquiry.addProperty("productType", (String) type[0]);
|
||||||
|
enquiry.addProperty("amount", amount);
|
||||||
|
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
|
||||||
|
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
|
||||||
|
|
||||||
|
final JsonObject a = new JsonObject();
|
||||||
|
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
|
||||||
|
a.add("consumer", consumer);
|
||||||
|
a.add("enquiry", enquiry);
|
||||||
|
return a;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String lastFour(final String value) {
|
||||||
|
final String digits = value.replaceAll("\\D", "");
|
||||||
|
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
|
||||||
|
}
|
||||||
|
}
|
||||||
+6
-235
@@ -83,13 +83,12 @@ public class KycContactAuditApiResource {
|
|||||||
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
|
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
|
||||||
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real) {
|
@QueryParam("search") final String search) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
final List<Object> args = new ArrayList<>();
|
||||||
final String where = filters(from, to, channel, status, search, real, args);
|
final String where = filters(from, to, channel, status, search, args);
|
||||||
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
||||||
+ "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, "
|
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
|
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
|
||||||
@@ -103,8 +102,6 @@ public class KycContactAuditApiResource {
|
|||||||
item.put("date", String.valueOf(r.get("d")));
|
item.put("date", String.valueOf(r.get("d")));
|
||||||
item.put("notices", number(r.get("notices")));
|
item.put("notices", number(r.get("notices")));
|
||||||
item.put("customers", number(r.get("customers")));
|
item.put("customers", number(r.get("customers")));
|
||||||
// Customers a notice actually reached that day, as against customers it was tried for.
|
|
||||||
item.put("notifiedCustomers", number(r.get("notified")));
|
|
||||||
item.put("delivered", number(r.get("delivered")));
|
item.put("delivered", number(r.get("delivered")));
|
||||||
item.put("failed", number(r.get("failed")));
|
item.put("failed", number(r.get("failed")));
|
||||||
item.put("noContact", number(r.get("no_contact")));
|
item.put("noContact", number(r.get("no_contact")));
|
||||||
@@ -121,12 +118,12 @@ public class KycContactAuditApiResource {
|
|||||||
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
|
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
|
||||||
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
|
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset,
|
||||||
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
|
@QueryParam("limit") final Integer limit) {
|
||||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
final List<Object> args = new ArrayList<>();
|
||||||
final String where = filters(from, to, channel, status, search, real, args);
|
final String where = filters(from, to, channel, status, search, args);
|
||||||
|
|
||||||
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
|
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
|
||||||
|
|
||||||
@@ -163,193 +160,10 @@ public class KycContactAuditApiResource {
|
|||||||
return this.gson.toJson(out);
|
return this.gson.toJson(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time.
|
|
||||||
* Answers "who was contacted, and what happened" without paging through every day.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("customers")
|
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.")
|
|
||||||
public String customers(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
|
||||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
|
||||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
|
|
||||||
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
|
||||||
|
|
||||||
final List<Object> args = new ArrayList<>();
|
|
||||||
final String where = filters(from, to, channel, status, search, real, args);
|
|
||||||
|
|
||||||
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + FROM_CLAUSE + where, Long.class,
|
|
||||||
args.toArray());
|
|
||||||
|
|
||||||
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
|
|
||||||
final int skip = Math.max(offset == null ? 0 : offset, 0);
|
|
||||||
final String sql = "SELECT l.client_id, c.display_name, c.account_no, COUNT(*) AS notices, "
|
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
|
||||||
+ "SUM(CASE WHEN l.delivery_status IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, "
|
|
||||||
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, "
|
|
||||||
+ "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where
|
|
||||||
+ " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET "
|
|
||||||
+ skip;
|
|
||||||
|
|
||||||
final List<Map<String, Object>> list = new ArrayList<>();
|
|
||||||
final List<Long> pageIds = new ArrayList<>();
|
|
||||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
|
||||||
final Map<String, Object> item = new LinkedHashMap<>();
|
|
||||||
item.put("clientId", r.get("client_id"));
|
|
||||||
item.put("clientName", r.get("display_name"));
|
|
||||||
item.put("accountNo", r.get("account_no"));
|
|
||||||
item.put("notices", number(r.get("notices")));
|
|
||||||
item.put("delivered", number(r.get("delivered")));
|
|
||||||
item.put("failed", number(r.get("failed")));
|
|
||||||
item.put("notSent", number(r.get("not_sent")));
|
|
||||||
item.put("recorded", number(r.get("recorded")));
|
|
||||||
final Object at = r.get("last_at");
|
|
||||||
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
|
||||||
pageIds.add(((Number) r.get("client_id")).longValue());
|
|
||||||
list.add(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The latest notice's channel and outcome, for the customers on this page only.
|
|
||||||
if (!pageIds.isEmpty()) {
|
|
||||||
final String marks = String.join(",", pageIds.stream().map(x -> "?").toList());
|
|
||||||
final Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
|
|
||||||
list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m));
|
|
||||||
final List<Object> latestArgs = new ArrayList<>(pageIds);
|
|
||||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(
|
|
||||||
"SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
|
||||||
+ ") ORDER BY contacted_at DESC, id DESC",
|
|
||||||
latestArgs.toArray())) {
|
|
||||||
final Map<String, Object> item = byId.get(((Number) r.get("client_id")).longValue());
|
|
||||||
if (item != null && !item.containsKey("lastChannel")) {
|
|
||||||
item.put("lastChannel", r.get("channel"));
|
|
||||||
item.put("lastStatus", r.get("delivery_status"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final Map<String, Object> out = new LinkedHashMap<>();
|
|
||||||
out.put("total", total == null ? 0 : total);
|
|
||||||
out.put("rows", list);
|
|
||||||
return this.gson.toJson(out);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the listed customers have been sent so far: the latest notice and which channels have been done.
|
|
||||||
* One round trip for a whole screen of customers, so a list can show "last notice" without a call per row.
|
|
||||||
*/
|
|
||||||
@GET
|
|
||||||
@Path("clients")
|
|
||||||
@Consumes(MediaType.APPLICATION_JSON)
|
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
|
||||||
@Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).")
|
|
||||||
public String clients(@QueryParam("ids") final String ids) {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
|
||||||
|
|
||||||
final List<Long> clientIds = new ArrayList<>();
|
|
||||||
if (StringUtils.isNotBlank(ids)) {
|
|
||||||
for (final String part : ids.split(",")) {
|
|
||||||
if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) {
|
|
||||||
clientIds.add(Long.valueOf(part.trim()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (clientIds.isEmpty()) {
|
|
||||||
return this.gson.toJson(new ArrayList<>());
|
|
||||||
}
|
|
||||||
|
|
||||||
final String marks = String.join(",", clientIds.stream().map(x -> "?").toList());
|
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
|
||||||
"SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
|
||||||
+ ") ORDER BY contacted_at DESC, id DESC",
|
|
||||||
clientIds.toArray());
|
|
||||||
|
|
||||||
final Map<Long, Map<String, Object>> byClient = new LinkedHashMap<>();
|
|
||||||
// Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email.
|
|
||||||
for (final Map<String, Object> c : this.jdbcTemplate.queryForList(
|
|
||||||
"SELECT c.id, c.mobile_no, c.email_address, "
|
|
||||||
+ "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) "
|
|
||||||
+ "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance "
|
|
||||||
+ "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) {
|
|
||||||
final Long id = ((Number) c.get("id")).longValue();
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
m.put("clientId", id);
|
|
||||||
m.put("count", 0);
|
|
||||||
m.put("lastAt", null);
|
|
||||||
m.put("lastChannel", null);
|
|
||||||
m.put("lastStatus", null);
|
|
||||||
m.put("doneChannels", new ArrayList<String>());
|
|
||||||
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
|
||||||
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
|
||||||
// What the customer holds in open savings and current accounts.
|
|
||||||
m.put("balance", c.get("balance"));
|
|
||||||
m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no")));
|
|
||||||
m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address")));
|
|
||||||
// Masked, never the full value: the screen only needs to show that something is on file.
|
|
||||||
m.put("mobileMasked", maskMobile((String) c.get("mobile_no")));
|
|
||||||
m.put("emailMasked", maskEmail((String) c.get("email_address")));
|
|
||||||
byClient.put(id, m);
|
|
||||||
}
|
|
||||||
for (final Map<String, Object> r : rows) {
|
|
||||||
final Long clientId = ((Number) r.get("client_id")).longValue();
|
|
||||||
final Map<String, Object> item = byClient.computeIfAbsent(clientId, k -> {
|
|
||||||
final Map<String, Object> m = new LinkedHashMap<>();
|
|
||||||
m.put("clientId", k);
|
|
||||||
m.put("count", 0);
|
|
||||||
m.put("lastAt", null);
|
|
||||||
m.put("lastChannel", null);
|
|
||||||
m.put("lastStatus", null);
|
|
||||||
m.put("doneChannels", new ArrayList<String>());
|
|
||||||
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
|
||||||
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
|
||||||
return m;
|
|
||||||
});
|
|
||||||
if (item.get("lastAt") == null) {
|
|
||||||
// Rows come newest first, so the first one seen is the latest notice.
|
|
||||||
final Object at = r.get("contacted_at");
|
|
||||||
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
|
||||||
item.put("lastChannel", r.get("channel"));
|
|
||||||
item.put("lastStatus", r.get("delivery_status"));
|
|
||||||
}
|
|
||||||
item.put("count", ((Integer) item.get("count")) + 1);
|
|
||||||
// A channel counts as done once it reached the customer, or was recorded (a call or a visit).
|
|
||||||
final String status = String.valueOf(r.get("delivery_status"));
|
|
||||||
final String channel = String.valueOf(r.get("channel"));
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final List<String> done = (List<String>) item.get("doneChannels");
|
|
||||||
if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) {
|
|
||||||
done.add(channel);
|
|
||||||
}
|
|
||||||
// The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one.
|
|
||||||
// A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at.
|
|
||||||
final String found = stageOfNotice((String) r.get("message_body"));
|
|
||||||
final String stage = found == null ? "ANY" : found;
|
|
||||||
if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) {
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final Map<String, List<String>> byStage = (Map<String, List<String>>) item.get("doneByStage");
|
|
||||||
@SuppressWarnings("unchecked")
|
|
||||||
final Map<String, Integer> counts = (Map<String, Integer>) item.get("noticesByStage");
|
|
||||||
counts.merge(stage, 1, Integer::sum);
|
|
||||||
final List<String> inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>());
|
|
||||||
if (!inStage.contains(channel)) {
|
|
||||||
inStage.add(channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.gson.toJson(new ArrayList<>(byClient.values()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
|
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
|
||||||
private String filters(final String from, final String to, final String channel, final String status, final String search,
|
private String filters(final String from, final String to, final String channel, final String status, final String search,
|
||||||
final Boolean real, final List<Object> args) {
|
final List<Object> args) {
|
||||||
final StringBuilder where = new StringBuilder();
|
final StringBuilder where = new StringBuilder();
|
||||||
if (Boolean.TRUE.equals(real)) {
|
|
||||||
// An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received.
|
|
||||||
where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')");
|
|
||||||
}
|
|
||||||
if (StringUtils.isNotBlank(from)) {
|
if (StringUtils.isNotBlank(from)) {
|
||||||
where.append(" AND l.contacted_at >= ?");
|
where.append(" AND l.contacted_at >= ?");
|
||||||
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
|
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
|
||||||
@@ -376,49 +190,6 @@ public class KycContactAuditApiResource {
|
|||||||
return where.toString();
|
return where.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call,
|
|
||||||
* a visit or any message that is not one of the stage notices.
|
|
||||||
*/
|
|
||||||
private static String stageOfNotice(final String body) {
|
|
||||||
if (StringUtils.isBlank(body)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (body.startsWith("Your KYC expires on")) {
|
|
||||||
return "EXPIRING";
|
|
||||||
}
|
|
||||||
if (body.startsWith("Your KYC expired on")) {
|
|
||||||
if (body.contains("account is now inoperative")) {
|
|
||||||
return "INOPERATIVE";
|
|
||||||
}
|
|
||||||
return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE";
|
|
||||||
}
|
|
||||||
if (body.startsWith("Your account ")) {
|
|
||||||
if (body.contains("has been closed")) {
|
|
||||||
return "ESCHEAT";
|
|
||||||
}
|
|
||||||
return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String maskMobile(final String mobile) {
|
|
||||||
if (StringUtils.isBlank(mobile)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
final String digits = mobile.trim();
|
|
||||||
return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String maskEmail(final String email) {
|
|
||||||
if (StringUtils.isBlank(email) || !email.contains("@")) {
|
|
||||||
return StringUtils.isBlank(email) ? null : email;
|
|
||||||
}
|
|
||||||
final String trimmed = email.trim();
|
|
||||||
final int at = trimmed.indexOf('@');
|
|
||||||
return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static long number(final Object value) {
|
private static long number(final Object value) {
|
||||||
return value == null ? 0 : ((Number) value).longValue();
|
return value == null ? 0 : ((Number) value).longValue();
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -108,7 +108,6 @@ public class KycNoticeQueueApiResource {
|
|||||||
final JsonObject out = new JsonObject();
|
final JsonObject out = new JsonObject();
|
||||||
out.addProperty("queued", result.queued());
|
out.addProperty("queued", result.queued());
|
||||||
out.addProperty("skipped", result.skipped());
|
out.addProperty("skipped", result.skipped());
|
||||||
out.addProperty("unreachable", result.unreachable());
|
|
||||||
return out.toString();
|
return out.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+11
@@ -24,6 +24,7 @@ import jakarta.ws.rs.Consumes;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.POST;
|
import jakarta.ws.rs.POST;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.PathParam;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
@@ -69,4 +70,14 @@ public class KycRiskRulesApiResource {
|
|||||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||||
return this.toApiJsonSerializer.serialize(rule);
|
return this.toApiJsonSerializer.serialize(rule);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@POST
|
||||||
|
@Path("{ruleId}/mark-normal")
|
||||||
|
@Consumes({ MediaType.APPLICATION_JSON })
|
||||||
|
@Produces({ MediaType.APPLICATION_JSON })
|
||||||
|
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
|
||||||
|
public String markNormal(@PathParam("ruleId") final Long ruleId) {
|
||||||
|
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||||
|
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-1
@@ -66,6 +66,10 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
private final BigDecimal txnMaxLimitAmount;
|
private final BigDecimal txnMaxLimitAmount;
|
||||||
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
||||||
private final boolean ccRelevant;
|
private final boolean ccRelevant;
|
||||||
|
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
|
||||||
|
private final boolean onTrial;
|
||||||
|
/** When the trial began (ISO date), or null for a rule that is not on trial. */
|
||||||
|
private final String trialStartedOn;
|
||||||
|
|
||||||
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||||
@@ -78,7 +82,8 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) {
|
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant,
|
||||||
|
final boolean onTrial, final String trialStartedOn) {
|
||||||
this.id = id;
|
this.id = id;
|
||||||
this.ruleName = ruleName;
|
this.ruleName = ruleName;
|
||||||
this.ruleKind = ruleKind;
|
this.ruleKind = ruleKind;
|
||||||
@@ -109,6 +114,8 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||||
this.ccRelevant = ccRelevant;
|
this.ccRelevant = ccRelevant;
|
||||||
|
this.onTrial = onTrial;
|
||||||
|
this.trialStartedOn = trialStartedOn;
|
||||||
}
|
}
|
||||||
|
|
||||||
public Long getId() {
|
public Long getId() {
|
||||||
@@ -230,4 +237,12 @@ public final class KycRiskRuleData implements Serializable {
|
|||||||
public boolean isCcRelevant() {
|
public boolean isCcRelevant() {
|
||||||
return ccRelevant;
|
return ccRelevant;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean isOnTrial() {
|
||||||
|
return onTrial;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getTrialStartedOn() {
|
||||||
|
return trialStartedOn;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-2
@@ -104,15 +104,26 @@ public class KycRiskRuleRequest {
|
|||||||
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||||
private String txnRuleType;
|
private String txnRuleType;
|
||||||
|
|
||||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
@Schema(description = "SAVINGS | LOAN | TRANSFER | CURRENT | ALL. Defaults to ALL. CURRENT is a current / Cash Credit account.", example = "ALL")
|
||||||
private String txnAppliesTo;
|
private String txnAppliesTo;
|
||||||
|
|
||||||
|
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
|
||||||
|
private String txnDirection;
|
||||||
|
|
||||||
|
@Schema(description = "True when the rule is about cash only. Defaults to false.")
|
||||||
|
private Boolean txnCashOnly;
|
||||||
|
|
||||||
|
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
|
||||||
|
private Boolean ccRelevant;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||||
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||||
*/
|
*/
|
||||||
public boolean hasTransactionRule() {
|
public boolean hasTransactionRule() {
|
||||||
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
|| (this.txnWindowDays != null && this.txnWindowDays > 0)
|
||||||
|
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|
||||||
|
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-16
@@ -122,36 +122,28 @@ public class KycNoticeComposer {
|
|||||||
return "Your account is inoperative";
|
return "Your account is inoperative";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What the customer is told, by the stage they are at: where they stand and what happens next, and in how many days. */
|
/** What the customer is told, by the stage they are at: where they stand and what happens next. */
|
||||||
public static String kycMessage(final LocalDate expiry, final long offset) {
|
public static String kycMessage(final LocalDate expiry, final long offset) {
|
||||||
final String expired = expiry.format(DATE);
|
final String expired = expiry.format(DATE);
|
||||||
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
|
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
|
||||||
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
|
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
|
||||||
final String freezeIn = days(DEBIT_FREEZE_DAYS - offset);
|
|
||||||
final String inoperativeIn = days(INOPERATIVE_DAYS - offset);
|
|
||||||
if (offset < 0) {
|
if (offset < 0) {
|
||||||
return "Your KYC expires on " + expired + " (in " + days(-offset) + "). Please visit your branch with valid ID and address"
|
return "Your KYC expires on " + expired + " (in " + (-offset) + " days). Please visit your branch with valid ID and address"
|
||||||
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze + " (in " + freezeIn
|
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze
|
||||||
+ ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ").";
|
+ " and the account will become inoperative on " + inoperative + ".";
|
||||||
}
|
}
|
||||||
if (offset < DEBIT_FREEZE_DAYS) {
|
if (offset < DEBIT_FREEZE_DAYS) {
|
||||||
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
|
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
|
||||||
+ " account will stop on " + freeze + " (in " + freezeIn + ") and the account will become inoperative on "
|
+ " account will stop on " + freeze + " and the account will become inoperative on " + inoperative + ".";
|
||||||
+ inoperative + " (in " + inoperativeIn + ").";
|
|
||||||
}
|
}
|
||||||
if (offset < INOPERATIVE_DAYS) {
|
if (offset < INOPERATIVE_DAYS) {
|
||||||
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
|
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
|
||||||
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + " (in " + inoperativeIn
|
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + ".";
|
||||||
+ ").";
|
|
||||||
}
|
}
|
||||||
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
|
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
|
||||||
+ " documents to renew your KYC and have the account restored.";
|
+ " documents to renew your KYC and have the account restored.";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static String days(final long n) {
|
|
||||||
return n == 1 ? "1 day" : n + " days";
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inactive / dormant / escheated accounts ────────────────────────────────
|
// ── Inactive / dormant / escheated accounts ────────────────────────────────
|
||||||
|
|
||||||
public static String dormantSubject(final DormantAccountData account) {
|
public static String dormantSubject(final DormantAccountData account) {
|
||||||
@@ -174,7 +166,7 @@ public class KycNoticeComposer {
|
|||||||
if (sub != null && sub == SUB_STATUS_DORMANT) {
|
if (sub != null && sub == SUB_STATUS_DORMANT) {
|
||||||
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
|
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
|
||||||
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
|
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
|
||||||
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + " (in " + days(account.getDaysToEscheat()) + ")."
|
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + "."
|
||||||
: "";
|
: "";
|
||||||
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
|
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
|
||||||
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
|
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
|
||||||
@@ -182,7 +174,7 @@ public class KycNoticeComposer {
|
|||||||
}
|
}
|
||||||
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
|
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
|
||||||
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
|
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
|
||||||
+ " (in " + days(account.getDaysToDormancy()) + ") and debits will be stopped."
|
+ " and debits will be stopped."
|
||||||
: "";
|
: "";
|
||||||
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
|
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
|
||||||
+ ". Please make a transaction or visit your branch." + dormant;
|
+ ". Please make a transaction or visit your branch." + dormant;
|
||||||
|
|||||||
+2
-5
@@ -36,11 +36,8 @@ public interface KycNoticeQueueService {
|
|||||||
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
|
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** How many were queued, and how many were left out because the same notice was already waiting. */
|
||||||
* How many were queued, how many were left out because the same notice was already waiting, and how many because the
|
record EnqueueResult(int queued, int skipped) {
|
||||||
* customer has no mobile number or email address to send it to.
|
|
||||||
*/
|
|
||||||
record EnqueueResult(int queued, int skipped, int unreachable) {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+1
-12
@@ -78,7 +78,6 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
|||||||
|
|
||||||
int queued = 0;
|
int queued = 0;
|
||||||
int skipped = 0;
|
int skipped = 0;
|
||||||
int unreachable = 0;
|
|
||||||
for (final QueueItem item : items) {
|
for (final QueueItem item : items) {
|
||||||
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
|
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
|
||||||
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
|
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
|
||||||
@@ -88,16 +87,6 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
|||||||
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
|
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
|
||||||
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
|
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
|
||||||
}
|
}
|
||||||
// A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a
|
|
||||||
// "not sent" record every time, so it is left out and counted — those customers are found and reached another way.
|
|
||||||
final Map<String, Object> contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?",
|
|
||||||
item.clientId()).stream().findFirst().orElse(null);
|
|
||||||
final String destination = contact == null ? null
|
|
||||||
: (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no");
|
|
||||||
if (StringUtils.isBlank(destination)) {
|
|
||||||
unreachable++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
|
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
|
||||||
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
|
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
|
||||||
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
|
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
|
||||||
@@ -113,7 +102,7 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
|||||||
Timestamp.valueOf(when), Timestamp.valueOf(now));
|
Timestamp.valueOf(when), Timestamp.valueOf(now));
|
||||||
queued++;
|
queued++;
|
||||||
}
|
}
|
||||||
return new EnqueueResult(queued, skipped, unreachable);
|
return new EnqueueResult(queued, skipped);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|||||||
+2
-14
@@ -57,15 +57,7 @@ import org.springframework.stereotype.Service;
|
|||||||
public class KycReminderServiceImpl implements KycReminderService {
|
public class KycReminderServiceImpl implements KycReminderService {
|
||||||
|
|
||||||
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
|
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
|
||||||
/**
|
private static final int[] KYC_MILESTONES = { -30, -15, -7, 0, 80, 90, 170, 180 };
|
||||||
* Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the
|
|
||||||
* day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and
|
|
||||||
* on each of those two days themselves.
|
|
||||||
*/
|
|
||||||
private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 };
|
|
||||||
|
|
||||||
/** An account is warned 7 days and 1 day before it moves to its next stage. */
|
|
||||||
private static final long WARN_DAYS = 7;
|
|
||||||
|
|
||||||
private static final int SUB_STATUS_INACTIVE = 100;
|
private static final int SUB_STATUS_INACTIVE = 100;
|
||||||
private static final int SUB_STATUS_DORMANT = 200;
|
private static final int SUB_STATUS_DORMANT = 200;
|
||||||
@@ -145,11 +137,7 @@ public class KycReminderServiceImpl implements KycReminderService {
|
|||||||
int attempted = 0;
|
int attempted = 0;
|
||||||
for (final DormantAccountData account : worst.values()) {
|
for (final DormantAccountData account : worst.values()) {
|
||||||
try {
|
try {
|
||||||
// The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day
|
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + "]";
|
||||||
// before). The latest one reached is what the customer is told, once.
|
|
||||||
final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy();
|
|
||||||
final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E";
|
|
||||||
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]";
|
|
||||||
final String subject = KycNoticeComposer.dormantSubject(account);
|
final String subject = KycNoticeComposer.dormantSubject(account);
|
||||||
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
|
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
|
||||||
} catch (final RuntimeException e) {
|
} catch (final RuntimeException e) {
|
||||||
|
|||||||
+3
-1
@@ -93,7 +93,9 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
|||||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||||
txnRule != null ? txnRule.getMaxLimitAmount() : null,
|
txnRule != null ? txnRule.getMaxLimitAmount() : null,
|
||||||
rule.isCcRelevant()
|
rule.isCcRelevant(),
|
||||||
|
rule.isOnTrial(),
|
||||||
|
rule.getTrialStartedOn() == null ? null : rule.getTrialStartedOn().toString()
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
@@ -31,4 +31,7 @@ public interface KycRiskRulesWritePlatformService {
|
|||||||
* @return the persisted rule exactly as the catalog GET would return it
|
* @return the persisted rule exactly as the catalog GET would return it
|
||||||
*/
|
*/
|
||||||
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
||||||
|
|
||||||
|
/** Ends a rule's trial: it stays in the catalog as a normal rule. Returns the rule as the catalog GET would. */
|
||||||
|
KycRiskRuleData markNormal(Long ruleId);
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-2
@@ -28,6 +28,7 @@ import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrappe
|
|||||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||||
|
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
||||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||||
@@ -81,6 +82,13 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
rule.setOccupation(occupation);
|
rule.setOccupation(occupation);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A rule written for a Cash Credit account, or marked so, is listed under the CC chip.
|
||||||
|
rule.markCcRelevant(Boolean.TRUE.equals(request.getCcRelevant())
|
||||||
|
|| "CURRENT".equalsIgnoreCase(StringUtils.defaultString(request.getTxnAppliesTo()).trim()));
|
||||||
|
|
||||||
|
// A rule authored here starts on trial, from the business date, until an officer marks it normal.
|
||||||
|
rule.startTrial(DateUtils.getBusinessLocalDate());
|
||||||
|
|
||||||
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
||||||
|
|
||||||
if (request.hasTransactionRule()) {
|
if (request.hasTransactionRule()) {
|
||||||
@@ -138,8 +146,21 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
||||||
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
||||||
|
|
||||||
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
|
final AmlTransactionRule transactionRule = AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo,
|
||||||
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
ruleType, request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||||
|
transactionRule.restrictTo(StringUtils.isBlank(request.getTxnDirection()) ? null : request.getTxnDirection().trim().toUpperCase(),
|
||||||
|
Boolean.TRUE.equals(request.getTxnCashOnly()));
|
||||||
|
return transactionRule;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
@Transactional
|
||||||
|
public KycRiskRuleData markNormal(final Long ruleId) {
|
||||||
|
final ClientRiskRule rule = this.repository.findById(ruleId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
|
||||||
|
rule.markNormal();
|
||||||
|
this.repository.saveAndFlush(rule);
|
||||||
|
return this.readPlatformService.retrieveRule(ruleId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private Integer nextOrderPosition() {
|
private Integer nextOrderPosition() {
|
||||||
@@ -182,6 +203,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
|||||||
|
|
||||||
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
||||||
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
||||||
|
if (StringUtils.isNotBlank(request.getTxnDirection()) && !"CREDIT".equalsIgnoreCase(request.getTxnDirection().trim())
|
||||||
|
&& !"DEBIT".equalsIgnoreCase(request.getTxnDirection().trim())) {
|
||||||
|
validator.reset().parameter("txnDirection").value(request.getTxnDirection()).failWithCode("must.be.credit.or.debit");
|
||||||
|
}
|
||||||
|
|
||||||
if (!errors.isEmpty()) {
|
if (!errors.isEmpty()) {
|
||||||
throw new PlatformApiDataValidationException(errors);
|
throw new PlatformApiDataValidationException(errors);
|
||||||
|
|||||||
+4
-2
@@ -148,14 +148,16 @@ public class CurrentAccountsApiResource {
|
|||||||
@QueryParam("status") @Parameter(description = "status") final String status,
|
@QueryParam("status") @Parameter(description = "status") final String status,
|
||||||
@QueryParam("fromDate") @Parameter(description = "fromDate (YYYY-MM-DD)") final String fromDate,
|
@QueryParam("fromDate") @Parameter(description = "fromDate (YYYY-MM-DD)") final String fromDate,
|
||||||
@QueryParam("toDate") @Parameter(description = "toDate (YYYY-MM-DD)") final String toDate,
|
@QueryParam("toDate") @Parameter(description = "toDate (YYYY-MM-DD)") final String toDate,
|
||||||
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate) {
|
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate,
|
||||||
|
@QueryParam("overdraftOnly") @Parameter(description = "only Cash Credit (overdraft) accounts") final Boolean overdraftOnly) {
|
||||||
|
|
||||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
|
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
|
||||||
final List<Map<String, Object>> accounts = currentAccountReadPlatformService.retrieveAll(offset, limit, accountNo, clientName,
|
final List<Map<String, Object>> accounts = currentAccountReadPlatformService.retrieveAll(offset, limit, accountNo, clientName,
|
||||||
status,
|
status,
|
||||||
fromDate != null ? LocalDate.parse(fromDate) : null,
|
fromDate != null ? LocalDate.parse(fromDate) : null,
|
||||||
toDate != null ? LocalDate.parse(toDate) : null,
|
toDate != null ? LocalDate.parse(toDate) : null,
|
||||||
asOnDate != null ? LocalDate.parse(asOnDate) : null);
|
asOnDate != null ? LocalDate.parse(asOnDate) : null,
|
||||||
|
Boolean.TRUE.equals(overdraftOnly));
|
||||||
return GSON.toJson(accounts);
|
return GSON.toJson(accounts);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-33
@@ -109,7 +109,6 @@ public class FixedDepositAccountsApiResource {
|
|||||||
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
||||||
private final SqlValidator sqlValidator;
|
private final SqlValidator sqlValidator;
|
||||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||||
private final org.apache.fineract.portfolio.savings.service.FixedDepositAccountPreviewService fixedDepositAccountPreviewService;
|
|
||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Path("template")
|
@Path("template")
|
||||||
@@ -316,38 +315,6 @@ public class FixedDepositAccountsApiResource {
|
|||||||
return toApiJsonSerializer.serializeResult(result);
|
return toApiJsonSerializer.serializeResult(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed deposit quote while the account is being opened, for every FD type: takes the same body as Submit
|
|
||||||
* (clientId, productId, submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, isSeniorCitizen,
|
|
||||||
* locale, dateFormat ...) and returns the rate, maturity date and maturity amount Submit would store, plus the
|
|
||||||
* interest periods (FD Special: the monthly payout schedule). Nothing is saved (see FixedDepositAccountPreviewService).
|
|
||||||
*/
|
|
||||||
@POST
|
|
||||||
@Path("account-preview")
|
|
||||||
@Consumes({ MediaType.APPLICATION_JSON })
|
|
||||||
@Produces({ MediaType.APPLICATION_JSON })
|
|
||||||
public String accountPreview(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
|
||||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
|
||||||
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
|
||||||
// Submit insists on a linked savings account - "linkAccountId" (FD Special's monthly payout account) and
|
|
||||||
// "linkedAccountId" (when the product's Deposit Account Link is on) - which the screen asks for after the quote.
|
|
||||||
// The quote does not depend on which account it is, only that the interest goes out monthly, so a stand-in id is
|
|
||||||
// used for whichever is not chosen yet. Both are only recorded on the account, never looked up, and the would-be
|
|
||||||
// account is never saved, so the stand-in goes nowhere.
|
|
||||||
if (parsed.isJsonObject()) {
|
|
||||||
for (final String linkParam : new String[] { DepositsApiConstants.linkedAccountParamName,
|
|
||||||
org.apache.fineract.portfolio.savings.SavingsApiConstants.linkedAccountIdParamName }) {
|
|
||||||
if (!parsed.getAsJsonObject().has(linkParam) || parsed.getAsJsonObject().get(linkParam).isJsonNull()) {
|
|
||||||
parsed.getAsJsonObject().addProperty(linkParam, -1L);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
|
|
||||||
.from(parsed.toString(), parsed, this.fromJsonHelper, DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME, null,
|
|
||||||
null, null, null, null, null, null, null, null, null, null, null, null);
|
|
||||||
return this.toApiJsonSerializer.serializeResult(this.fixedDepositAccountPreviewService.preview(command));
|
|
||||||
}
|
|
||||||
|
|
||||||
private BigDecimal getActivationCharge(Long accountId) {
|
private BigDecimal getActivationCharge(Long accountId) {
|
||||||
BigDecimal activationCharge = BigDecimal.ZERO;
|
BigDecimal activationCharge = BigDecimal.ZERO;
|
||||||
Collection<SavingsAccountChargeData> savingCharges = this.savingsAccountChargeReadPlatformService
|
Collection<SavingsAccountChargeData> savingCharges = this.savingsAccountChargeReadPlatformService
|
||||||
|
|||||||
-1
@@ -49,6 +49,5 @@ public class RdInterestRateSaveRequest {
|
|||||||
private Integer fromPeriod;
|
private Integer fromPeriod;
|
||||||
private Integer toPeriod;
|
private Integer toPeriod;
|
||||||
private BigDecimal annualInterestRate;
|
private BigDecimal annualInterestRate;
|
||||||
private BigDecimal seniorCitizenRate;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -23,7 +23,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
|||||||
import jakarta.ws.rs.GET;
|
import jakarta.ws.rs.GET;
|
||||||
import jakarta.ws.rs.Path;
|
import jakarta.ws.rs.Path;
|
||||||
import jakarta.ws.rs.Produces;
|
import jakarta.ws.rs.Produces;
|
||||||
import jakarta.ws.rs.QueryParam;
|
|
||||||
import jakarta.ws.rs.core.MediaType;
|
import jakarta.ws.rs.core.MediaType;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
@@ -72,7 +71,6 @@ public class RecurringDepositDashboardApiResource {
|
|||||||
JOIN m_client c ON c.id = sa.client_id
|
JOIN m_client c ON c.id = sa.client_id
|
||||||
JOIN m_office o ON o.id = c.office_id
|
JOIN m_office o ON o.id = c.office_id
|
||||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
|
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
|
||||||
AND (CAST(? AS BIGINT) IS NULL OR sa.id = CAST(? AS BIGINT))
|
|
||||||
),
|
),
|
||||||
paid AS (
|
paid AS (
|
||||||
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
|
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
|
||||||
@@ -180,7 +178,7 @@ public class RecurringDepositDashboardApiResource {
|
|||||||
|
|
||||||
@GET
|
@GET
|
||||||
@Produces(MediaType.APPLICATION_JSON)
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
public String retrieve(@QueryParam("accountId") final Long accountId) {
|
public String retrieve() {
|
||||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||||
|
|
||||||
@@ -189,7 +187,7 @@ public class RecurringDepositDashboardApiResource {
|
|||||||
final LocalDate monthStart = today.withDayOfMonth(1);
|
final LocalDate monthStart = today.withDayOfMonth(1);
|
||||||
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
|
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
|
||||||
|
|
||||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy, accountId, accountId,
|
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
|
||||||
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
|
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
|
||||||
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
|
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
|
||||||
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
|
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
|
||||||
|
|||||||
-119
@@ -1,119 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.savings.data;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
|
||||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
|
||||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
|
||||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
|
||||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
|
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
|
||||||
import org.springframework.stereotype.Component;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FD Special is a non-cumulative product: its interest is paid out to the linked savings account every month, so the
|
|
||||||
* principal never grows and nothing compounds on it - in effect simple interest on the principal. Its product terms are
|
|
||||||
* therefore fixed, and the product screen locks them:
|
|
||||||
* <ul>
|
|
||||||
* <li>Interest Calculation Type ({@code interestCalculateSelector}) = Simple</li>
|
|
||||||
* <li>Interest Compounding Period = Monthly</li>
|
|
||||||
* <li>Interest Posting Period = Monthly</li>
|
|
||||||
* </ul>
|
|
||||||
* Checked here, on the server, against the product as it will be saved, so a request from Postman or any other client
|
|
||||||
* that sends different values is refused with a validation error rather than saved. Used on create, on update (which
|
|
||||||
* the maker-checker approval also goes through) and when a maker submits an edit for approval.
|
|
||||||
*/
|
|
||||||
@Component
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FdSpecialProductTermsValidator {
|
|
||||||
|
|
||||||
private static final String RESOURCE = "fixeddepositproduct";
|
|
||||||
private static final String SIMPLE = "Simple";
|
|
||||||
|
|
||||||
private final JdbcTemplate jdbcTemplate;
|
|
||||||
|
|
||||||
/** The product as assembled or updated, just before it is saved. */
|
|
||||||
public void validate(final FixedDepositProduct product) {
|
|
||||||
validate(product.getFdType(), product.getInterestCalculateSelector(), valueOf(product.interestCompoundingPeriodType()),
|
|
||||||
valueOf(product.interestPostingPeriodType()));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A maker's edit, before it waits for approval: each field as the edit leaves it - the submitted value where the
|
|
||||||
* request sends one, otherwise the product's current value.
|
|
||||||
*/
|
|
||||||
public void validateEdit(final FixedDepositProduct product, final JsonCommand command) {
|
|
||||||
final String fdType = command.parameterExists("fdType") ? command.stringValueOfParameterNamed("fdType") : product.getFdType();
|
|
||||||
final Integer selector = command.parameterExists("interestCalculateSelector")
|
|
||||||
? command.integerValueOfParameterNamed("interestCalculateSelector")
|
|
||||||
: product.getInterestCalculateSelector();
|
|
||||||
final Integer compounding = command.parameterExists("interestCompoundingPeriodType")
|
|
||||||
? command.integerValueOfParameterNamed("interestCompoundingPeriodType")
|
|
||||||
: valueOf(product.interestCompoundingPeriodType());
|
|
||||||
final Integer posting = command.parameterExists("interestPostingPeriodType")
|
|
||||||
? command.integerValueOfParameterNamed("interestPostingPeriodType")
|
|
||||||
: valueOf(product.interestPostingPeriodType());
|
|
||||||
validate(fdType, selector, compounding, posting);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void validate(final String fdType, final Integer interestCalculateSelector, final Integer compoundingPeriodType,
|
|
||||||
final Integer postingPeriodType) {
|
|
||||||
if (!"SPECIAL".equalsIgnoreCase(fdType)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final List<ApiParameterError> errors = new ArrayList<>();
|
|
||||||
|
|
||||||
final String calculationType = interestCalculateSelector == null ? null
|
|
||||||
: this.jdbcTemplate.queryForList("SELECT code_value FROM m_code_value WHERE id = ?", String.class, interestCalculateSelector)
|
|
||||||
.stream().findFirst().orElse(null);
|
|
||||||
if (calculationType == null || !SIMPLE.equalsIgnoreCase(calculationType.trim())) {
|
|
||||||
errors.add(ApiParameterError.parameterError("validation.msg." + RESOURCE + ".special.interestCalculateSelector.must.be.simple",
|
|
||||||
"FD Special pays its interest out every month, so nothing compounds: Interest Calculation Type must be Simple"
|
|
||||||
+ (calculationType == null ? "." : ", not " + calculationType + "."),
|
|
||||||
"interestCalculateSelector", interestCalculateSelector));
|
|
||||||
}
|
|
||||||
if (compoundingPeriodType == null || compoundingPeriodType != SavingsCompoundingInterestPeriodType.MONTHLY.getValue()) {
|
|
||||||
errors.add(ApiParameterError.parameterError(
|
|
||||||
"validation.msg." + RESOURCE + ".special.interestCompoundingPeriodType.must.be.monthly",
|
|
||||||
"FD Special pays its interest out every month: Interest Compounding Period must be Monthly.",
|
|
||||||
"interestCompoundingPeriodType", compoundingPeriodType));
|
|
||||||
}
|
|
||||||
if (postingPeriodType == null || postingPeriodType != SavingsPostingInterestPeriodType.MONTHLY.getValue()) {
|
|
||||||
errors.add(ApiParameterError.parameterError(
|
|
||||||
"validation.msg." + RESOURCE + ".special.interestPostingPeriodType.must.be.monthly",
|
|
||||||
"FD Special pays its interest out every month: Interest Posting Period must be Monthly.", "interestPostingPeriodType",
|
|
||||||
postingPeriodType));
|
|
||||||
}
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer valueOf(final SavingsCompoundingInterestPeriodType type) {
|
|
||||||
return type == null ? null : type.getValue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Integer valueOf(final SavingsPostingInterestPeriodType type) {
|
|
||||||
return type == null ? null : type.getValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-1
@@ -27,5 +27,4 @@ public class RdInterestRateSlabData {
|
|||||||
private Integer fromPeriod;
|
private Integer fromPeriod;
|
||||||
private Integer toPeriod;
|
private Integer toPeriod;
|
||||||
private BigDecimal annualInterestRate;
|
private BigDecimal annualInterestRate;
|
||||||
private BigDecimal seniorCitizenRate;
|
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-7
@@ -52,13 +52,8 @@ public class RdBaseInterestRateSlabHistory {
|
|||||||
@Column(name = "annual_interest_rate")
|
@Column(name = "annual_interest_rate")
|
||||||
private BigDecimal annualInterestRate;
|
private BigDecimal annualInterestRate;
|
||||||
|
|
||||||
/** The rate a senior citizen earns on this slab; zero means the slab carries no senior premium. */
|
|
||||||
@Column(name = "senior_citizen_rate")
|
|
||||||
private BigDecimal seniorCitizenRate;
|
|
||||||
|
|
||||||
public static RdBaseInterestRateSlabHistory create(Long effectiveId, Integer srNo,
|
public static RdBaseInterestRateSlabHistory create(Long effectiveId, Integer srNo,
|
||||||
String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate,
|
String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate) {
|
||||||
BigDecimal seniorCitizenRate) {
|
|
||||||
RdBaseInterestRateSlabHistory s = new RdBaseInterestRateSlabHistory();
|
RdBaseInterestRateSlabHistory s = new RdBaseInterestRateSlabHistory();
|
||||||
s.effectiveId = effectiveId;
|
s.effectiveId = effectiveId;
|
||||||
s.srNo = srNo;
|
s.srNo = srNo;
|
||||||
@@ -67,7 +62,6 @@ public class RdBaseInterestRateSlabHistory {
|
|||||||
s.fromPeriod = fromPeriod;
|
s.fromPeriod = fromPeriod;
|
||||||
s.toPeriod = toPeriod;
|
s.toPeriod = toPeriod;
|
||||||
s.annualInterestRate = annualInterestRate;
|
s.annualInterestRate = annualInterestRate;
|
||||||
s.seniorCitizenRate = seniorCitizenRate != null ? seniorCitizenRate : BigDecimal.ZERO;
|
|
||||||
return s;
|
return s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
@@ -446,6 +446,13 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
|||||||
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||||
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||||
final LocalDate asOnDate) {
|
final LocalDate asOnDate) {
|
||||||
|
return retrieveAll(offset, limit, accountNo, clientName, status, fromDate, toDate, asOnDate, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||||
|
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||||
|
final LocalDate asOnDate, final boolean overdraftOnly) {
|
||||||
final StringBuilder sqlBuilder = new StringBuilder(BASE_SQL);
|
final StringBuilder sqlBuilder = new StringBuilder(BASE_SQL);
|
||||||
final List<Object> params = new ArrayList<>();
|
final List<Object> params = new ArrayList<>();
|
||||||
|
|
||||||
@@ -457,6 +464,11 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
|||||||
params.add(this.context.officeHierarchy() + "%");
|
params.add(this.context.officeHierarchy() + "%");
|
||||||
boolean hasWhere = true;
|
boolean hasWhere = true;
|
||||||
|
|
||||||
|
// A Cash Credit account is a current account that is allowed an overdraft.
|
||||||
|
if (overdraftOnly) {
|
||||||
|
sqlBuilder.append(" AND ca.allow_overdraft = true");
|
||||||
|
}
|
||||||
|
|
||||||
if (StringUtils.isNotBlank(accountNo)) {
|
if (StringUtils.isNotBlank(accountNo)) {
|
||||||
sqlBuilder.append(hasWhere ? " AND " : " WHERE ");
|
sqlBuilder.append(hasWhere ? " AND " : " WHERE ");
|
||||||
sqlBuilder.append("ca.account_no LIKE ?");
|
sqlBuilder.append("ca.account_no LIKE ?");
|
||||||
|
|||||||
-148
@@ -1,148 +0,0 @@
|
|||||||
/**
|
|
||||||
* Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
* or more contributor license agreements. See the NOTICE file
|
|
||||||
* distributed with this work for additional information
|
|
||||||
* regarding copyright ownership. The ASF licenses this file
|
|
||||||
* to you under the Apache License, Version 2.0 (the
|
|
||||||
* "License"); you may not use this file except in compliance
|
|
||||||
* with the License. You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing,
|
|
||||||
* software distributed under the License is distributed on an
|
|
||||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
* KIND, either express or implied. See the License for the
|
|
||||||
* specific language governing permissions and limitations
|
|
||||||
* under the License.
|
|
||||||
*/
|
|
||||||
package org.apache.fineract.portfolio.savings.service;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
|
||||||
import java.math.MathContext;
|
|
||||||
import java.math.RoundingMode;
|
|
||||||
import java.time.LocalDate;
|
|
||||||
import java.time.temporal.ChronoUnit;
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.LinkedHashMap;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
|
||||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
|
||||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
|
||||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
|
||||||
import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler;
|
|
||||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
|
|
||||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
|
||||||
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
|
||||||
import org.springframework.stereotype.Service;
|
|
||||||
import org.springframework.transaction.PlatformTransactionManager;
|
|
||||||
import org.springframework.transaction.support.TransactionTemplate;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fixed deposit quote for the account-opening screen - every FD type - worked out by the account engine itself rather
|
|
||||||
* than by the screen or a separate calculator.
|
|
||||||
* <p>
|
|
||||||
* The would-be account is built exactly as Submit builds it ({@code submitFDApplication}): the same assembler (product
|
|
||||||
* defaults, the product's interest rate chart, the CBS base-rate card and senior-citizen rate), the same maturity
|
|
||||||
* calculation, the same CBS fields. Nothing is saved: it all runs in a transaction that is always rolled back. So the
|
|
||||||
* rate, maturity date and maturity amount quoted are exactly what Submit would store - including the product's posting
|
|
||||||
* periods (e.g. Quarterly posting closes each period at the calendar quarter end, from the financial-year start month),
|
|
||||||
* which a stand-alone formula that counts quarters from the deposit date gets wrong.
|
|
||||||
* <p>
|
|
||||||
* The interest periods come from {@link FixedDepositAccount#projectInterestSchedule}, the method the account's Monthly
|
|
||||||
* Interest Payout Schedule uses once it is open. For FD Special they are the monthly payouts; for a cumulative FD they
|
|
||||||
* are the periods interest is credited to the deposit and compounds.
|
|
||||||
* <p>
|
|
||||||
* The schedule assumes the deposit starts on the submitted date; once opened, the account's own schedule runs from its
|
|
||||||
* actual activation date.
|
|
||||||
*/
|
|
||||||
@Service
|
|
||||||
@RequiredArgsConstructor
|
|
||||||
public class FixedDepositAccountPreviewService {
|
|
||||||
|
|
||||||
private final PlatformSecurityContext context;
|
|
||||||
private final DepositAccountAssembler depositAccountAssembler;
|
|
||||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
|
||||||
private final ConfigurationDomainService configurationDomainService;
|
|
||||||
private final PlatformTransactionManager transactionManager;
|
|
||||||
|
|
||||||
public Map<String, Object> preview(final JsonCommand command) {
|
|
||||||
final TransactionTemplate template = new TransactionTemplate(this.transactionManager);
|
|
||||||
return template.execute(status -> {
|
|
||||||
// Never keep anything the assembler touched: this is a quote, not an application.
|
|
||||||
status.setRollbackOnly();
|
|
||||||
return buildPreview(command);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private Map<String, Object> buildPreview(final JsonCommand command) {
|
|
||||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
|
||||||
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
|
||||||
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
|
||||||
|
|
||||||
// Same steps, in the same order, as DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl#submitFDApplication.
|
|
||||||
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command,
|
|
||||||
this.context.authenticatedUser(), DepositAccountType.FIXED_DEPOSIT);
|
|
||||||
final boolean special = "SPECIAL".equalsIgnoreCase(account.fdType());
|
|
||||||
account.updateMaturityDateAndAmountBeforeAccountActivation(MathContext.DECIMAL64, false,
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
|
|
||||||
|
|
||||||
// Same MathContext and clipping as DepositAccountWritePlatformServiceJpaRepositoryImpl#retrieveProjectedInterestSchedule.
|
|
||||||
final RoundingMode rounding = MoneyHelper.getRoundingMode();
|
|
||||||
final List<PostingPeriod> postingPeriods = account.projectInterestSchedule(new MathContext(15, rounding),
|
|
||||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
|
||||||
final LocalDate maturityDate = account.maturityDate();
|
|
||||||
final LocalDate lastCalculableDate = maturityDate != null ? maturityDate.minusDays(1) : null;
|
|
||||||
|
|
||||||
final List<Map<String, Object>> periods = new ArrayList<>();
|
|
||||||
BigDecimal totalInterest = BigDecimal.ZERO;
|
|
||||||
int number = 1;
|
|
||||||
for (final PostingPeriod period : postingPeriods) {
|
|
||||||
final LocalDate fromDate = period.getPeriodInterval().startDate();
|
|
||||||
LocalDate toDate = period.getPeriodInterval().endDate();
|
|
||||||
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
|
|
||||||
toDate = lastCalculableDate;
|
|
||||||
}
|
|
||||||
final BigDecimal interest = period.getInterestEarned().getAmount();
|
|
||||||
totalInterest = totalInterest.add(interest);
|
|
||||||
final Map<String, Object> row = new LinkedHashMap<>();
|
|
||||||
row.put("period", number++);
|
|
||||||
row.put("fromDate", fromDate.toString());
|
|
||||||
row.put("toDate", toDate.toString());
|
|
||||||
row.put("days", ChronoUnit.DAYS.between(fromDate, toDate) + 1);
|
|
||||||
row.put("interestAmount", interest);
|
|
||||||
periods.add(row);
|
|
||||||
}
|
|
||||||
|
|
||||||
final BigDecimal depositAmount = account.getDepositAmount();
|
|
||||||
final BigDecimal rate = account.getNominalAnnualInterestRate();
|
|
||||||
final Map<String, Object> result = new LinkedHashMap<>();
|
|
||||||
result.put("fdType", account.fdType());
|
|
||||||
result.put("depositAmount", depositAmount);
|
|
||||||
result.put("annualInterestRate", rate);
|
|
||||||
result.put("seniorCitizen", account.isSeniorCitizen());
|
|
||||||
result.put("interestPostingPeriodType", account.getInterestPostingPeriodType());
|
|
||||||
result.put("interestCalculationDaysInYearType", account.getInterestCalculationDaysInYearType());
|
|
||||||
result.put("startDate", periods.isEmpty() ? null : periods.get(0).get("fromDate"));
|
|
||||||
result.put("maturityDate", maturityDate == null ? null : maturityDate.toString());
|
|
||||||
result.put("numberOfPayouts", special ? periods.size() : 0);
|
|
||||||
// FD Special: one full month at the applied rate; a period pays for the days it actually covers (see periods).
|
|
||||||
result.put("fullMonthPayout", !special || rate == null ? null
|
|
||||||
: depositAmount.multiply(rate, MoneyHelper.getMathContext()).divide(BigDecimal.valueOf(1200), MoneyHelper.getMathContext())
|
|
||||||
.setScale(2, rounding));
|
|
||||||
// The account's own maturity amount, as Submit stores it: for FD Special the principal alone (the interest is paid
|
|
||||||
// out month by month), otherwise principal plus the compounded interest.
|
|
||||||
final BigDecimal maturityAmount = account.maturityAmount();
|
|
||||||
final BigDecimal interest = special ? totalInterest
|
|
||||||
: (maturityAmount == null ? null : maturityAmount.subtract(depositAmount));
|
|
||||||
result.put("totalInterest", interest);
|
|
||||||
result.put("maturityAmount", maturityAmount);
|
|
||||||
result.put("totalValueAtMaturity", interest == null ? null : depositAmount.add(interest));
|
|
||||||
result.put("periods", periods);
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-7
@@ -89,7 +89,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||||
private final FromJsonHelper fromApiJsonHelper;
|
private final FromJsonHelper fromApiJsonHelper;
|
||||||
private final CommandSourceRepository commandSourceRepository;
|
private final CommandSourceRepository commandSourceRepository;
|
||||||
private final org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator;
|
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Override
|
@Override
|
||||||
@@ -99,8 +98,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json());
|
this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json());
|
||||||
|
|
||||||
final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command);
|
final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command);
|
||||||
// FD Special: Simple interest, monthly compounding and posting - refused here whatever client sent it.
|
|
||||||
this.fdSpecialProductTermsValidator.validate(product);
|
|
||||||
|
|
||||||
this.fixedDepositProductRepository.saveAndFlush(product);
|
this.fixedDepositProductRepository.saveAndFlush(product);
|
||||||
|
|
||||||
@@ -139,8 +136,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
product.setHelpers(this.chartAssembler);
|
product.setHelpers(this.chartAssembler);
|
||||||
|
|
||||||
final Map<String, Object> changes = product.update(command);
|
final Map<String, Object> changes = product.update(command);
|
||||||
// The product as this update leaves it (also the path an approved maker-checker edit takes).
|
|
||||||
this.fdSpecialProductTermsValidator.validate(product);
|
|
||||||
|
|
||||||
if (changes.containsKey(chargesParamName)) {
|
if (changes.containsKey(chargesParamName)) {
|
||||||
final Set<Charge> savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command,
|
final Set<Charge> savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command,
|
||||||
@@ -202,8 +197,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json());
|
this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json());
|
||||||
// Refused before it waits for a checker, not only when the checker approves it.
|
|
||||||
this.fdSpecialProductTermsValidator.validateEdit(product, command);
|
|
||||||
|
|
||||||
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker);
|
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker);
|
||||||
|
|
||||||
|
|||||||
+6
-12
@@ -77,7 +77,7 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
|||||||
final int sr = slabRequest.getSrNo() != null ? slabRequest.getSrNo() : srCounter.getAndIncrement();
|
final int sr = slabRequest.getSrNo() != null ? slabRequest.getSrNo() : srCounter.getAndIncrement();
|
||||||
final RdBaseInterestRateSlabHistory slab = RdBaseInterestRateSlabHistory.create(versionId, sr, slabRequest.getDescription(),
|
final RdBaseInterestRateSlabHistory slab = RdBaseInterestRateSlabHistory.create(versionId, sr, slabRequest.getDescription(),
|
||||||
slabRequest.getPeriodTypeEnum(), slabRequest.getFromPeriod(), slabRequest.getToPeriod(),
|
slabRequest.getPeriodTypeEnum(), slabRequest.getFromPeriod(), slabRequest.getToPeriod(),
|
||||||
slabRequest.getAnnualInterestRate(), slabRequest.getSeniorCitizenRate());
|
slabRequest.getAnnualInterestRate());
|
||||||
slabRepository.save(slab);
|
slabRepository.save(slab);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,16 +111,13 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
|||||||
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
|
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
|
||||||
|
|
||||||
final List<RdInterestRateSaveRequest.SlabRequest> normalized = new ArrayList<>(sorted.size());
|
final List<RdInterestRateSaveRequest.SlabRequest> normalized = new ArrayList<>(sorted.size());
|
||||||
// A chart in days may open at 0; one in months or years opens at 1. Either way each slab then follows on from the last.
|
Integer expectedFrom = 0;
|
||||||
Integer expectedFrom = null;
|
|
||||||
for (int i = 0; i < sorted.size(); i++) {
|
for (int i = 0; i < sorted.size(); i++) {
|
||||||
final RdInterestRateSaveRequest.SlabRequest source = sorted.get(i);
|
final RdInterestRateSaveRequest.SlabRequest source = sorted.get(i);
|
||||||
final RdInterestRateSaveRequest.SlabRequest target = new RdInterestRateSaveRequest.SlabRequest();
|
final RdInterestRateSaveRequest.SlabRequest target = new RdInterestRateSaveRequest.SlabRequest();
|
||||||
|
|
||||||
final Integer fromPeriod = source.getFromPeriod();
|
final Integer fromPeriod = source.getFromPeriod();
|
||||||
final boolean startsRight = fromPeriod != null
|
if (fromPeriod == null || !fromPeriod.equals(expectedFrom)) {
|
||||||
&& (i == 0 ? (fromPeriod == 0 || fromPeriod == 1) : fromPeriod.equals(expectedFrom));
|
|
||||||
if (!startsRight) {
|
|
||||||
throwValidationError("fromPeriod", "must.start.from.previous.range.end");
|
throwValidationError("fromPeriod", "must.start.from.previous.range.end");
|
||||||
}
|
}
|
||||||
target.setFromPeriod(fromPeriod);
|
target.setFromPeriod(fromPeriod);
|
||||||
@@ -150,15 +147,13 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
|||||||
if (target.getAnnualInterestRate().signum() < 0) {
|
if (target.getAnnualInterestRate().signum() < 0) {
|
||||||
throwValidationError("annualInterestRate", "must.be.non.negative");
|
throwValidationError("annualInterestRate", "must.be.non.negative");
|
||||||
}
|
}
|
||||||
// The senior-citizen rate is optional: left blank it means this slab carries no premium.
|
|
||||||
target.setSeniorCitizenRate(source.getSeniorCitizenRate() == null ? java.math.BigDecimal.ZERO : source.getSeniorCitizenRate());
|
|
||||||
if (target.getSeniorCitizenRate().signum() < 0) {
|
|
||||||
throwValidationError("seniorCitizenRate", "must.be.non.negative");
|
|
||||||
}
|
|
||||||
|
|
||||||
normalized.add(target);
|
normalized.add(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!normalized.isEmpty() && normalized.get(0).getFromPeriod() != 0) {
|
||||||
|
throwValidationError("fromPeriod", "must.start.from.zero");
|
||||||
|
}
|
||||||
|
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -186,7 +181,6 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
|||||||
.fromPeriod(s.getFromPeriod())
|
.fromPeriod(s.getFromPeriod())
|
||||||
.toPeriod(s.getToPeriod())
|
.toPeriod(s.getToPeriod())
|
||||||
.annualInterestRate(s.getAnnualInterestRate())
|
.annualInterestRate(s.getAnnualInterestRate())
|
||||||
.seniorCitizenRate(s.getSeniorCitizenRate())
|
|
||||||
.build())
|
.build())
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
|||||||
+2
-3
@@ -367,12 +367,11 @@ public class SavingsConfiguration {
|
|||||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||||
FromJsonHelper fromApiJsonHelper,
|
FromJsonHelper fromApiJsonHelper,
|
||||||
CommandSourceRepository commandSourceRepository,
|
CommandSourceRepository commandSourceRepository) {
|
||||||
org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator) {
|
|
||||||
return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository,
|
return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository,
|
||||||
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
|
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
|
||||||
productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository,
|
productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository,
|
||||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, fdSpecialProductTermsValidator);
|
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -178,6 +178,8 @@
|
|||||||
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0604_cic_credit_bureau_module.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0605_cic_enquiry_alerts.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0599_current_account_notes.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0599_current_account_notes.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0600_create_profit_appropriation_config.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0600_create_profit_appropriation_config.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0601_create_profit_appropriation_posting.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0601_create_profit_appropriation_posting.xml" relativeToChangelogFile="true"/>
|
||||||
@@ -185,8 +187,7 @@
|
|||||||
<include file="parts/0603_savings_account_branch_and_client_branch_link.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0603_savings_account_branch_and_client_branch_link.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0604_add_kyc_reminders_job.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0604_add_kyc_reminders_job.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0605_create_kyc_notice_queue.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0605_create_kyc_notice_queue.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0606_rd_base_chart_senior_rate_and_months.xml" relativeToChangelogFile="true"/>
|
|
||||||
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0608_risk_rule_trial.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
+294
@@ -0,0 +1,294 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
CIC / Credit Bureau Enquiry module (CIBIL TransUnion, Experian, Equifax, CRIF High Mark).
|
||||||
|
|
||||||
|
m_cic_bureau - the bank's bureau master: whitelisted or not, environment, connection and credentials
|
||||||
|
(the secret is stored encrypted with the tenant master password).
|
||||||
|
m_cic_bureau_endpoint - per bureau and enquiry type: method, path, headers and a base JSON body.
|
||||||
|
m_cic_field_mapping - the API mapping, maintained from the UI: REQUEST rows put a CBS field into the bureau's
|
||||||
|
request at a JSON path (with a transform); RESPONSE rows read a JSON path of the bureau's
|
||||||
|
response into a CBS result field (score, accounts, overdue ...).
|
||||||
|
m_cic_consent - the borrower's consent to a bureau pull, required before any enquiry.
|
||||||
|
m_cic_enquiry - every enquiry: masked request, raw response, normalised result, score and status.
|
||||||
|
|
||||||
|
Every bureau is seeded NOT whitelisted, in SIMULATOR mode, with a generic request/response mapping the built-in
|
||||||
|
simulator answers to. Going live is: enter the bureau's URL and credentials, adapt the mapping to the bureau's own
|
||||||
|
specification, switch the environment, and whitelist it.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0604-1-cic-tables" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_cic_bureau"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_cic_bureau">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="code" type="VARCHAR(30)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_bureau_code"/></column>
|
||||||
|
<column name="name" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||||
|
<column name="description" type="VARCHAR(500)"/>
|
||||||
|
<column name="whitelisted" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||||
|
<column name="is_default" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||||
|
<column name="environment" type="VARCHAR(20)" defaultValue="SIMULATOR"><constraints nullable="false"/></column>
|
||||||
|
<column name="base_url" type="VARCHAR(500)"/>
|
||||||
|
<column name="member_code" type="VARCHAR(100)"/>
|
||||||
|
<column name="auth_type" type="VARCHAR(40)" defaultValue="NONE"><constraints nullable="false"/></column>
|
||||||
|
<column name="username" type="VARCHAR(200)"/>
|
||||||
|
<column name="api_key_header" type="VARCHAR(100)" defaultValue="X-API-KEY"/>
|
||||||
|
<column name="secret_enc" type="TEXT"/>
|
||||||
|
<column name="token_url" type="VARCHAR(500)"/>
|
||||||
|
<column name="client_id" type="VARCHAR(200)"/>
|
||||||
|
<column name="timeout_ms" type="INT" defaultValueNumeric="30000"><constraints nullable="false"/></column>
|
||||||
|
<column name="report_validity_days" type="INT" defaultValueNumeric="30"><constraints nullable="false"/></column>
|
||||||
|
<column name="enquiry_charge" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="last_test_status" type="VARCHAR(20)"/>
|
||||||
|
<column name="last_test_message" type="VARCHAR(1000)"/>
|
||||||
|
<column name="last_tested_on" type="TIMESTAMP"/>
|
||||||
|
<column name="updated_by" type="BIGINT"/>
|
||||||
|
<column name="updated_on" type="TIMESTAMP"/>
|
||||||
|
</createTable>
|
||||||
|
|
||||||
|
<createTable tableName="m_cic_bureau_endpoint">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="bureau_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_endpoint_bureau" references="m_cic_bureau(id)"/></column>
|
||||||
|
<column name="endpoint_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||||
|
<column name="http_method" type="VARCHAR(10)" defaultValue="POST"><constraints nullable="false"/></column>
|
||||||
|
<column name="path" type="VARCHAR(500)"/>
|
||||||
|
<column name="content_type" type="VARCHAR(100)" defaultValue="application/json"/>
|
||||||
|
<column name="headers_json" type="TEXT"/>
|
||||||
|
<column name="base_body" type="TEXT"/>
|
||||||
|
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
|
||||||
|
<column name="updated_by" type="BIGINT"/>
|
||||||
|
<column name="updated_on" type="TIMESTAMP"/>
|
||||||
|
</createTable>
|
||||||
|
<addUniqueConstraint tableName="m_cic_bureau_endpoint" columnNames="bureau_id, endpoint_type" constraintName="uq_cic_endpoint_type"/>
|
||||||
|
|
||||||
|
<createTable tableName="m_cic_field_mapping">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="endpoint_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_mapping_endpoint" references="m_cic_bureau_endpoint(id)" deleteCascade="true"/></column>
|
||||||
|
<column name="direction" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||||
|
<column name="source_field" type="VARCHAR(200)"/>
|
||||||
|
<column name="target_field" type="VARCHAR(200)"><constraints nullable="false"/></column>
|
||||||
|
<column name="transform" type="VARCHAR(40)" defaultValue="NONE"/>
|
||||||
|
<column name="default_value" type="VARCHAR(200)"/>
|
||||||
|
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||||
|
<column name="sort_order" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_cic_field_mapping" indexName="idx_cic_mapping_endpoint"><column name="endpoint_id"/></createIndex>
|
||||||
|
|
||||||
|
<createTable tableName="m_cic_consent">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="consent_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_consent_ref"/></column>
|
||||||
|
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||||
|
<column name="client_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="application_no" type="VARCHAR(50)"/>
|
||||||
|
<column name="bureau_id" type="BIGINT"/>
|
||||||
|
<column name="purpose" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||||
|
<column name="consent_mode" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||||
|
<column name="consent_text" type="TEXT"/>
|
||||||
|
<column name="document_ref" type="VARCHAR(200)"/>
|
||||||
|
<column name="otp_reference" type="VARCHAR(100)"/>
|
||||||
|
<column name="valid_from" type="DATE"><constraints nullable="false"/></column>
|
||||||
|
<column name="valid_until" type="DATE"><constraints nullable="false"/></column>
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"><constraints nullable="false"/></column>
|
||||||
|
<column name="revoked_reason" type="VARCHAR(500)"/>
|
||||||
|
<column name="revoked_by" type="BIGINT"/>
|
||||||
|
<column name="revoked_on" type="TIMESTAMP"/>
|
||||||
|
<column name="captured_by" type="BIGINT"/>
|
||||||
|
<column name="captured_by_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="captured_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||||
|
<column name="ip_address" type="VARCHAR(64)"/>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_cic_consent" indexName="idx_cic_consent_client"><column name="client_id"/></createIndex>
|
||||||
|
|
||||||
|
<createTable tableName="m_cic_enquiry">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="enquiry_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_enquiry_ref"/></column>
|
||||||
|
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||||
|
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||||
|
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||||
|
<column name="client_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="application_no" type="VARCHAR(50)"/>
|
||||||
|
<column name="consent_id" type="BIGINT"/>
|
||||||
|
<column name="enquiry_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||||
|
<column name="purpose" type="VARCHAR(40)"/>
|
||||||
|
<column name="loan_amount" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||||
|
<column name="score" type="INT"/>
|
||||||
|
<column name="http_status" type="INT"/>
|
||||||
|
<column name="error_message" type="VARCHAR(2000)"/>
|
||||||
|
<column name="request_masked" type="TEXT"/>
|
||||||
|
<column name="response_raw" type="TEXT"/>
|
||||||
|
<column name="result_json" type="TEXT"/>
|
||||||
|
<column name="reused_from_id" type="BIGINT"/>
|
||||||
|
<column name="charge_amount" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="environment" type="VARCHAR(20)"/>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
<column name="requested_by" type="BIGINT"/>
|
||||||
|
<column name="requested_by_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="requested_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||||
|
<column name="duration_ms" type="INT"/>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_client"><column name="client_id"/></createIndex>
|
||||||
|
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_bureau_date"><column name="bureau_id"/><column name="requested_on"/></createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- The four RBI-licensed CICs. Seeded off (not whitelisted) and in SIMULATOR mode. -->
|
||||||
|
<changeSet id="0604-2-cic-bureaus" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_cic_bureau">
|
||||||
|
<column name="code" value="CIBIL"/><column name="name" value="TransUnion CIBIL"/>
|
||||||
|
<column name="description" value="TransUnion CIBIL Limited - consumer and commercial credit information"/>
|
||||||
|
<column name="is_default" valueBoolean="true"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_cic_bureau">
|
||||||
|
<column name="code" value="EXPERIAN"/><column name="name" value="Experian Credit Information Company of India"/>
|
||||||
|
<column name="description" value="Experian India - consumer and commercial credit information"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_cic_bureau">
|
||||||
|
<column name="code" value="EQUIFAX"/><column name="name" value="Equifax Credit Information Services"/>
|
||||||
|
<column name="description" value="Equifax India - consumer, commercial and microfinance credit information"/>
|
||||||
|
</insert>
|
||||||
|
<insert tableName="m_cic_bureau">
|
||||||
|
<column name="code" value="CRIF"/><column name="name" value="CRIF High Mark Credit Information Services"/>
|
||||||
|
<column name="description" value="CRIF High Mark - consumer, commercial and microfinance credit information"/>
|
||||||
|
</insert>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- A CONSUMER_ENQUIRY endpoint per bureau, with a generic body skeleton the simulator understands. -->
|
||||||
|
<changeSet id="0604-3-cic-endpoints" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau_endpoint</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
|
||||||
|
SELECT b.id, 'CONSUMER_ENQUIRY', 'POST', '/consumer/enquiry', 'application/json',
|
||||||
|
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{enquiry.reference}}"}',
|
||||||
|
'{"requestHeader":{"product":"CONSUMER_REPORT","version":"1.0"},"consumer":{},"enquiry":{}}',
|
||||||
|
TRUE
|
||||||
|
FROM m_cic_bureau b
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- The generic API mapping. Adapt it to each bureau's own specification from the API Mapping screen. -->
|
||||||
|
<changeSet id="0604-4-cic-mappings" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_field_mapping</sqlCheck>
|
||||||
|
</preConditions>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
|
||||||
|
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
|
||||||
|
FROM m_cic_bureau_endpoint e
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
|
||||||
|
UNION ALL SELECT 'REQUEST', 'enquiry.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
|
||||||
|
UNION ALL SELECT 'REQUEST', 'enquiry.date', 'requestHeader.enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 30
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.firstName', 'consumer.name.firstName', 'UPPER', NULL, TRUE, 40
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.middleName', 'consumer.name.middleName', 'UPPER', NULL, FALSE, 50
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.lastName', 'consumer.name.lastName', 'UPPER', NULL, FALSE, 60
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.dob', 'consumer.dateOfBirth', 'DATE_DDMMYYYY', NULL, TRUE, 70
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.gender', 'consumer.gender', 'GENDER_MF', NULL, FALSE, 80
|
||||||
|
UNION ALL SELECT 'REQUEST', NULL, 'consumer.ids[0].type', 'NONE', 'PAN', FALSE, 90
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.pan', 'consumer.ids[0].number', 'UPPER', NULL, TRUE, 100
|
||||||
|
UNION ALL SELECT 'REQUEST', NULL, 'consumer.phones[0].type', 'NONE', 'MOBILE', FALSE, 110
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.mobile', 'consumer.phones[0].number', 'DIGITS_ONLY', NULL, FALSE, 120
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.email', 'consumer.email', 'LOWER', NULL, FALSE, 130
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.addressLine1', 'consumer.address.line1', 'UPPER', NULL, FALSE, 140
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.addressLine2', 'consumer.address.line2', 'UPPER', NULL, FALSE, 150
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.city', 'consumer.address.city', 'UPPER', NULL, FALSE, 160
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.state', 'consumer.address.stateCode', 'STATE_CODE', NULL, FALSE, 170
|
||||||
|
UNION ALL SELECT 'REQUEST', 'client.pincode', 'consumer.address.pincode', 'DIGITS_ONLY', NULL, FALSE, 180
|
||||||
|
UNION ALL SELECT 'REQUEST', 'enquiry.amount', 'enquiry.amount', 'AMOUNT_INTEGER', NULL, TRUE, 190
|
||||||
|
UNION ALL SELECT 'REQUEST', 'enquiry.purposeCode', 'enquiry.purpose', 'NONE', NULL, TRUE, 200
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.hit', 'hit', 'NONE', NULL, FALSE, 10
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.reportId', 'reportId', 'NONE', NULL, FALSE, 20
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.score.value', 'score', 'NUMBER', NULL, FALSE, 30
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.score.version', 'scoreVersion', 'NONE', NULL, FALSE, 40
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.totalAccounts', 'totalAccounts', 'NUMBER', NULL, FALSE, 50
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.activeAccounts', 'activeAccounts', 'NUMBER', NULL, FALSE, 60
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.overdueAccounts', 'overdueAccounts', 'NUMBER', NULL, FALSE, 70
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.writtenOffAccounts', 'writtenOffAccounts', 'NUMBER', NULL, FALSE, 80
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOutstanding', 'totalOutstanding', 'NUMBER', NULL, FALSE, 90
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOverdue', 'totalOverdue', 'NUMBER', NULL, FALSE, 100
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.enquiriesLast6Months', 'enquiriesLast6Months', 'NUMBER', NULL, FALSE, 110
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.summary.maxDpd', 'maxDpd', 'NUMBER', NULL, FALSE, 120
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.accounts', 'accounts', 'NONE', NULL, FALSE, 130
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'report.enquiries', 'enquiries', 'NONE', NULL, FALSE, 140
|
||||||
|
) m
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Permissions. Configuration (UPDATE_CICBUREAU) is left to super users; enquiry and consent go to every role that
|
||||||
|
already works Loan Origination, which also gets the new top-nav menu. -->
|
||||||
|
<changeSet id="0604-5-cic-permissions" author="cbsnexora">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'heading_menu', 'MENU_CREDIT_BUREAU', 'MENU', 'CREDIT_BUREAU', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'MENU_CREDIT_BUREAU');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'READ_CICBUREAU', 'CICBUREAU', 'READ', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICBUREAU');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'UPDATE_CICBUREAU', 'CICBUREAU', 'UPDATE', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICBUREAU');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'READ_CICENQUIRY', 'CICENQUIRY', 'READ', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICENQUIRY');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'CREATE_CICENQUIRY', 'CICENQUIRY', 'CREATE', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICENQUIRY');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'READ_CICCONSENT', 'CICCONSENT', 'READ', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICCONSENT');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'CREATE_CICCONSENT', 'CICCONSENT', 'CREATE', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICCONSENT');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'REVOKE_CICCONSENT', 'CICCONSENT', 'REVOKE', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVOKE_CICCONSENT');
|
||||||
|
</sql>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT DISTINCT rp.role_id, p2.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_LOAN_ORIGINATION'
|
||||||
|
JOIN m_permission p2 ON p2.code IN ('MENU_CREDIT_BUREAU', 'READ_CICBUREAU', 'READ_CICENQUIRY', 'CREATE_CICENQUIRY',
|
||||||
|
'READ_CICCONSENT', 'CREATE_CICCONSENT')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0604-6-cic-menu-access" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<and>
|
||||||
|
<tableExists tableName="m_tenant_menu_access"/>
|
||||||
|
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU'</sqlCheck>
|
||||||
|
</and>
|
||||||
|
</preConditions>
|
||||||
|
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CREDIT_BUREAU"/></insert>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
+207
@@ -0,0 +1,207 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<!--
|
||||||
|
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
|
||||||
|
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
|
||||||
|
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
|
||||||
|
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
|
||||||
|
|
||||||
|
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
|
||||||
|
CONSUMER_ENQUIRY endpoints / mappings.
|
||||||
|
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
|
||||||
|
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
|
||||||
|
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||||
|
|
||||||
|
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
|
||||||
|
<sql>
|
||||||
|
DROP TABLE IF EXISTS m_cic_enquiry;
|
||||||
|
DROP TABLE IF EXISTS m_cic_consent;
|
||||||
|
DELETE FROM m_cic_field_mapping
|
||||||
|
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
|
||||||
|
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
|
||||||
|
DELETE FROM m_role_permission
|
||||||
|
WHERE permission_id IN (SELECT id FROM m_permission
|
||||||
|
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
|
||||||
|
'REVOKE_CICCONSENT'));
|
||||||
|
DELETE FROM m_permission
|
||||||
|
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||||
|
</preConditions>
|
||||||
|
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||||
|
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
|
||||||
|
<addColumn tableName="m_cic_bureau">
|
||||||
|
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
|
||||||
|
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
|
||||||
|
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
|
||||||
|
<column name="last_fetch_on" type="TIMESTAMP"/>
|
||||||
|
<column name="last_fetch_status" type="VARCHAR(20)"/>
|
||||||
|
<column name="last_fetch_message" type="VARCHAR(1000)"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><tableExists tableName="m_cic_alert"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<createTable tableName="m_cic_alert_fetch_run">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
|
||||||
|
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||||
|
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||||
|
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||||
|
<column name="environment" type="VARCHAR(20)"/>
|
||||||
|
<column name="from_date" type="DATE"/>
|
||||||
|
<column name="to_date" type="DATE"/>
|
||||||
|
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||||
|
<column name="http_status" type="INT"/>
|
||||||
|
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||||
|
<column name="error_message" type="VARCHAR(2000)"/>
|
||||||
|
<column name="request_masked" type="TEXT"/>
|
||||||
|
<column name="response_raw" type="TEXT"/>
|
||||||
|
<column name="duration_ms" type="INT"/>
|
||||||
|
<column name="triggered_by" type="BIGINT"/>
|
||||||
|
<column name="triggered_by_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||||
|
</createTable>
|
||||||
|
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
|
||||||
|
|
||||||
|
<createTable tableName="m_cic_alert">
|
||||||
|
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||||
|
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||||
|
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||||
|
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||||
|
<column name="client_id" type="BIGINT"/>
|
||||||
|
<column name="office_id" type="BIGINT"/>
|
||||||
|
<column name="client_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="client_account_no" type="VARCHAR(50)"/>
|
||||||
|
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||||
|
<column name="customer_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="pan_masked" type="VARCHAR(20)"/>
|
||||||
|
<column name="aadhaar_masked" type="VARCHAR(20)"/>
|
||||||
|
<column name="customer_ref" type="VARCHAR(100)"/>
|
||||||
|
<column name="date_of_birth" type="DATE"/>
|
||||||
|
<column name="institution_code" type="VARCHAR(100)"/>
|
||||||
|
<column name="institution_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="institution_type" type="VARCHAR(60)"/>
|
||||||
|
<column name="enquiry_date" type="DATE"/>
|
||||||
|
<column name="loan_type" type="VARCHAR(100)"/>
|
||||||
|
<column name="amount" type="DECIMAL(19,6)"/>
|
||||||
|
<column name="purpose" type="VARCHAR(200)"/>
|
||||||
|
<column name="location" type="VARCHAR(200)"/>
|
||||||
|
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
|
||||||
|
<column name="remarks" type="VARCHAR(1000)"/>
|
||||||
|
<column name="actioned_by" type="BIGINT"/>
|
||||||
|
<column name="actioned_by_name" type="VARCHAR(200)"/>
|
||||||
|
<column name="actioned_on" type="TIMESTAMP"/>
|
||||||
|
<column name="received_via" type="VARCHAR(20)"/>
|
||||||
|
<column name="fetch_run_id" type="BIGINT"/>
|
||||||
|
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||||
|
<column name="raw_json" type="TEXT"/>
|
||||||
|
</createTable>
|
||||||
|
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
|
||||||
|
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
|
||||||
|
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
|
||||||
|
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
|
||||||
|
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
|
||||||
|
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
|
||||||
|
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
|
||||||
|
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
|
||||||
|
TRUE
|
||||||
|
FROM m_cic_bureau b
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
|
||||||
|
</sql>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
|
||||||
|
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
|
||||||
|
FROM m_cic_bureau_endpoint e
|
||||||
|
CROSS JOIN (
|
||||||
|
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
|
||||||
|
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
|
||||||
|
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
|
||||||
|
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
|
||||||
|
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
|
||||||
|
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
|
||||||
|
) m
|
||||||
|
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
|
||||||
|
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
|
||||||
|
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||||
|
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
|
||||||
|
</sql>
|
||||||
|
<sql>
|
||||||
|
INSERT INTO m_role_permission (role_id, permission_id)
|
||||||
|
SELECT DISTINCT rp.role_id, p2.id
|
||||||
|
FROM m_role_permission rp
|
||||||
|
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
|
||||||
|
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
|
||||||
|
</sql>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
The RD base chart gets a senior-citizen rate on every slab, like the FD one, so a recurring deposit
|
|
||||||
can price a senior citizen from its own chart.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<not>
|
|
||||||
<columnExists tableName="m_rd_base_interest_rate_slab_history" columnName="senior_citizen_rate"/>
|
|
||||||
</not>
|
|
||||||
</preConditions>
|
|
||||||
<addColumn tableName="m_rd_base_interest_rate_slab_history">
|
|
||||||
<column name="senior_citizen_rate" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
|
||||||
</addColumn>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
<!--
|
|
||||||
RD products used to take their rates from the FD "Double" chart (years) plus, below a year, from this RD
|
|
||||||
chart (days). Recurring deposits now take everything from this chart alone, in months, so it is given one
|
|
||||||
chart that prices exactly what the two did together: the same rates, in months, with the senior-citizen
|
|
||||||
rate the FD chart carried for a year or longer. Below a year no senior rate existed; the same 0.50 premium
|
|
||||||
the longer slabs carry is given to those too. It only runs while the active chart is still the old one in days, and the
|
|
||||||
chart can be edited on the CBS Base Interest Rate screen.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="2">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="1">
|
|
||||||
SELECT CASE WHEN EXISTS (
|
|
||||||
SELECT 1 FROM m_rd_base_interest_rate_slab_history s
|
|
||||||
JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id
|
|
||||||
WHERE e.is_active = true AND s.period_type_enum = 1
|
|
||||||
) THEN 1 ELSE 0 END
|
|
||||||
</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
UPDATE m_rd_base_interest_rate_effective SET is_active = false, effective_to_date = CURRENT_DATE WHERE is_active = true;
|
|
||||||
</sql>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_rd_base_interest_rate_effective (version_label, effective_from_date, is_active, remarks, created_date)
|
|
||||||
VALUES ('RD-MONTHS-V1', CURRENT_DATE, true, 'RD base chart in months, carrying the senior-citizen rate', CURRENT_DATE);
|
|
||||||
</sql>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 1, '1 to 3 Months', 2, 1, 3, 3, 3.5, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 2, '4 to 5 Months', 2, 4, 5, 4.5, 5, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 3, '6 to 11 Months', 2, 6, 11, 5.5, 6, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 4, '12 to 35 Months', 2, 12, 35, 6.5, 7, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 5, '36 to 71 Months', 2, 36, 71, 6.75, 7.25, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 6, '72 to 131 Months', 2, 72, 131, 7, 7.5, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
|
||||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
|
||||||
SELECT e.id, 7, '132+ Months', 2, 132, NULL, 7.25, 7.75, CURRENT_DATE
|
|
||||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
-74
@@ -1,74 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!--
|
|
||||||
|
|
||||||
Licensed to the Apache Software Foundation (ASF) under one
|
|
||||||
or more contributor license agreements. See the NOTICE file
|
|
||||||
distributed with this work for additional information
|
|
||||||
regarding copyright ownership. The ASF licenses this file
|
|
||||||
to you under the Apache License, Version 2.0 (the
|
|
||||||
"License"); you may not use this file except in compliance
|
|
||||||
with the License. You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing,
|
|
||||||
software distributed under the License is distributed on an
|
|
||||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
||||||
KIND, either express or implied. See the License for the
|
|
||||||
specific language governing permissions and limitations
|
|
||||||
under the License.
|
|
||||||
|
|
||||||
-->
|
|
||||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
|
||||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
||||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
|
||||||
|
|
||||||
<!--
|
|
||||||
Recurring deposit products now take their rates from the RD base chart. A product keeps its own copy of the
|
|
||||||
chart it was created with, so products made before that change still carry the old one, built from the FD
|
|
||||||
"Double" chart. This gives every existing RD product the active RD base chart, once.
|
|
||||||
|
|
||||||
Only the product's chart is replaced. Accounts already open keep the rates they were opened at, and the
|
|
||||||
product's own minimum and maximum terms are untouched. It runs only when the active RD chart is in months
|
|
||||||
or years, the units a product's chart can hold.
|
|
||||||
-->
|
|
||||||
<changeSet author="fineract" id="1">
|
|
||||||
<preConditions onFail="MARK_RAN">
|
|
||||||
<sqlCheck expectedResult="1">
|
|
||||||
SELECT CASE WHEN
|
|
||||||
EXISTS (SELECT 1 FROM m_rd_base_interest_rate_effective WHERE is_active = true)
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1 FROM m_rd_base_interest_rate_slab_history s
|
|
||||||
JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id
|
|
||||||
WHERE e.is_active = true AND s.period_type_enum NOT IN (2, 3))
|
|
||||||
THEN 1 ELSE 0 END
|
|
||||||
</sqlCheck>
|
|
||||||
</preConditions>
|
|
||||||
<sql>
|
|
||||||
UPDATE m_interest_rate_chart
|
|
||||||
SET name = (SELECT MAX(e.version_label) FROM m_rd_base_interest_rate_effective e WHERE e.id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true)),
|
|
||||||
description = 'Active RD CBS base chart'
|
|
||||||
WHERE id IN (
|
|
||||||
SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc
|
|
||||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id
|
|
||||||
WHERE sp.deposit_type_enum = 300);
|
|
||||||
</sql>
|
|
||||||
<sql>
|
|
||||||
DELETE FROM m_interest_rate_slab
|
|
||||||
WHERE interest_rate_chart_id IN (
|
|
||||||
SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc
|
|
||||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id
|
|
||||||
WHERE sp.deposit_type_enum = 300);
|
|
||||||
</sql>
|
|
||||||
<sql>
|
|
||||||
INSERT INTO m_interest_rate_slab
|
|
||||||
(interest_rate_chart_id, description, period_type_enum, from_period, to_period, annual_interest_rate, currency_code)
|
|
||||||
SELECT dpc.interest_rate_chart_id, s.description, s.period_type_enum, s.from_period, s.to_period, s.annual_interest_rate, sp.currency_code
|
|
||||||
FROM m_deposit_product_interest_rate_chart dpc
|
|
||||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id AND sp.deposit_type_enum = 300
|
|
||||||
JOIN m_rd_base_interest_rate_slab_history s
|
|
||||||
ON s.effective_id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true);
|
|
||||||
</sql>
|
|
||||||
</changeSet>
|
|
||||||
|
|
||||||
</databaseChangeLog>
|
|
||||||
+42
@@ -0,0 +1,42 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
|
||||||
|
Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
or more contributor license agreements. See the NOTICE file
|
||||||
|
distributed with this work for additional information
|
||||||
|
regarding copyright ownership. The ASF licenses this file
|
||||||
|
to you under the Apache License, Version 2.0 (the
|
||||||
|
"License"); you may not use this file except in compliance
|
||||||
|
with the License. You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing,
|
||||||
|
software distributed under the License is distributed on an
|
||||||
|
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
KIND, either express or implied. See the License for the
|
||||||
|
specific language governing permissions and limitations
|
||||||
|
under the License.
|
||||||
|
|
||||||
|
-->
|
||||||
|
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
A rule authored from the AML Rules Master starts on trial: it is listed first, marked as new, and an officer marks it
|
||||||
|
normal once it has been in use for as long as they want to watch it. Every rule already in the catalog is normal.
|
||||||
|
-->
|
||||||
|
<changeSet author="fineract" id="0608-1-risk-rule-trial">
|
||||||
|
<preConditions onFail="MARK_RAN">
|
||||||
|
<not><columnExists tableName="m_client_risk_rule" columnName="on_trial"/></not>
|
||||||
|
</preConditions>
|
||||||
|
<addColumn tableName="m_client_risk_rule">
|
||||||
|
<column name="on_trial" type="BOOLEAN" defaultValueBoolean="false">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="trial_started_on" type="DATE"/>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
</databaseChangeLog>
|
||||||
-1
@@ -27,5 +27,4 @@ public final class RdInterestRateSlabData {
|
|||||||
private final Integer fromPeriod;
|
private final Integer fromPeriod;
|
||||||
private final Integer toPeriod;
|
private final Integer toPeriod;
|
||||||
private final BigDecimal annualInterestRate;
|
private final BigDecimal annualInterestRate;
|
||||||
private final BigDecimal seniorCitizenRate;
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user