Compare commits

..
Author SHA1 Message Date
keshav ae69d126a9 Add CC accounts tracking, AML rule trial and branch approval fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-19 18:10:52 +05:30
niteshandClaude Sonnet 5.5 e4a2c00d75 feat(cic): switch CIC module to enquiry-alert service
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Remove CIBIL enquiry/consent flows; add alert fetch (manual + scheduled),
customer matching, fetch-run log, dashboard and ENQUIRY_ALERT mapping
(Liquibase 0605).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 15:14:18 +05:30
niteshandClaude Opus 5.5 a8f8aa39b7 feat(cic): credit bureau (CIBIL / CIC) enquiry module
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Whitelist and configure CICs (CIBIL, Experian, Equifax, CRIF) with encrypted
credentials, UI-driven request/response API mapping, consent register,
enquiries with report reuse, simulator mode, and a MENU_CREDIT_BUREAU menu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:17:50 +05:30
56 changed files with 4223 additions and 1465 deletions
@@ -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());
@@ -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;
} }
@@ -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.
@@ -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);
} }
-2
View File
@@ -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',
@@ -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);
@@ -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}.
@@ -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);
try {
this.savingsAccountWritePlatformService.undoTransaction( this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
accountTransfer.getFromTransaction().getId(), true); 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 {
this.savingsAccountWritePlatformService.undoTransaction(
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
accountTransfer.getToSavingsTransaction().getId(), true); 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,
@@ -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());
} }
@@ -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;
}
}
}
@@ -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) {}
@@ -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);
}
}
@@ -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) {}
@@ -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'"));
}
@@ -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);
}
@@ -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"));
}
}
@@ -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.
}
}
}
@@ -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));
}
}
@@ -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();
}
}
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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;
}
}
}
}
@@ -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";
}
}
@@ -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();
} }
@@ -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();
} }
@@ -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));
}
} }
@@ -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;
}
} }
@@ -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()));
} }
} }
@@ -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;
@@ -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) {
} }
/** /**
@@ -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
@@ -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) {
@@ -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()
); );
} }
} }
@@ -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);
} }
@@ -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);
@@ -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);
} }
@@ -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
@@ -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;
} }
} }
@@ -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);
@@ -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();
}
}
@@ -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;
} }
@@ -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;
} }
} }
@@ -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 ?");
@@ -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;
}
}
@@ -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);
@@ -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());
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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;
} }