feat: separate kyc/aml risk rules, dormancy clock, and savings daily interest fixes
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 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 - 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 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 Build & Cucumber tests (without E2E tests) / verify (main, build-core) (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 6 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (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 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 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (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

This commit is contained in:
2026-09-10 18:27:50 +05:30
parent 6aa655a8a3
commit 5cc5d680f2
34 changed files with 1777 additions and 159 deletions
@@ -61,6 +61,7 @@ import java.math.MathContext;
import java.time.LocalDate;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -1434,10 +1435,17 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
addTransaction(transaction);
}
if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue())
|| this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) {
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
}
// Nothing on the dormancy ladder is cleared by a transaction — not dormancy, and not
// inactivity either.
//
// RBI's Master Direction makes periodic re-verification the thing that keeps an account
// operative, and a lapsed one operable again only once the customer's identity has been
// established afresh. A cash-in at the counter is not that verification, so it cannot
// stand in for it: the sub-status is cleared by a completed re-KYC and by nothing else
// (see reviveOnReKyc), and DormancyClock measures the ladder from that same re-KYC.
//
// Both used to be cleared here, which meant a ₹100 deposit quietly did the work the
// branch was supposed to do at the counter.
if (backdatedTxnsAllowedTill) {
this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction,
@@ -1512,6 +1520,37 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
throw new PlatformApiDataValidationException(dataValidationErrors);
}
// A dormant account does not pay out until someone has looked at it.
//
// Dormancy means nobody has operated the account for the product's dormancy period — two
// years here — and an account nobody has touched for two years is the one an impersonator
// reaches for, because the real customer is not watching it. RBI's Master Direction on KYC
// requires the operations of such an account to be allowed only after the customer's
// identity is established afresh, so the debit is stopped and the account is revived only
// by a completed re-KYC.
//
// Credits are deliberately not stopped: a deposit puts money in, cannot be a fraud against
// the customer, and is the customer coming back.
//
// The elapsed days are counted here rather than trusting the sub-status alone. The nightly
// job is what stamps DORMANT, so between an account passing the threshold and that job next
// running the flag still says the account is fine — and a withdrawal in that window used to
// go straight through. It is a real gap: the job can be behind after an outage, or the
// business date can move by more than a day.
if (isDormantByElapsedDays()) {
final String defaultUserMessage = "Withdrawal is not allowed. The account is dormant — no customer-initiated"
+ " transaction for " + dormancyPeriodDescription() + ". It must be reactivated after the customer's"
+ " identity is re-verified.";
final ApiParameterError error = ApiParameterError.parameterError(
"error.msg.savingsaccount.transaction.withdrawals.blocked.account.dormant", defaultUserMessage, "transactionDate",
transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()));
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
dataValidationErrors.add(error);
throw new PlatformApiDataValidationException(dataValidationErrors);
}
if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) {
final String defaultUserMessage = "Transaction date cannot be in the future.";
final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future",
@@ -1570,10 +1609,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
addTransaction(transaction);
}
if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue())
|| this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) {
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
}
// A withdrawal clears nothing on the dormancy ladder either — see the deposit path for why.
if (backdatedTxnsAllowedTill) {
this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction,
this.savingsAccountTransactions);
@@ -1581,6 +1617,52 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return transaction;
}
/**
* How long the product says an account must sit untouched before it is dormant, for the message
* the customer is shown. Read from the product so the figure quoted is the one actually applied.
*/
private String dormancyPeriodDescription() {
final Long days = this.product == null ? null : this.product.getDaysToDormancy();
if (days == null || days <= 0) {
return "the product's dormancy period";
}
final long years = days / 365;
return years >= 1 ? years + (years == 1 ? " year" : " years") : days + " days";
}
/**
* Whether this account is dormant, by the sub-status the nightly job stamps or by the days that
* have actually elapsed since the customer last operated it.
*
* <p>
* The elapsed count is what closes the window between an account crossing the dormancy
* threshold and the nightly job next running: until that job runs the sub-status still says the
* account is fine, and a withdrawal in that window went straight through. The job can be a day
* behind after an outage, and a business date moved forward by more than a day skips the runs
* in between entirely.
*
* <p>
* The count mirrors the job's own query exactly, so the two can never disagree about whether an
* account is dormant: only deposits and withdrawals count as the customer operating the
* account — interest and charges are the bank's own movements, not the customer's — reversed
* transactions are ignored, and an account with no such transaction is measured from its
* activation. Dormancy tracking being switched off on the product turns this off with it.
*/
/**
* Whether this account is dormant, from the sub-status the nightly job stamped.
*
* <p>
* The elapsed days are not recounted here, though the read services and the clerk's withdrawal
* check both do. The clock now runs from the customer's last completed re-KYC — see
* DormancyClock — and this module cannot see a client's KYC record, so counting here would use
* transactions and give a different answer from every other place that asks. A guard that
* disagrees with the screen the officer just read is worse than one that lags the job by a
* night, and the clerk's own check catches that gap before a request is ever raised.
*/
private boolean isDormantByElapsedDays() {
return SavingsAccountSubStatusEnum.DORMANT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status));
}
public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) {
BigDecimal result = BigDecimal.ZERO;
if (isWithdrawalFeeApplicableForTransfer()) {
@@ -4278,6 +4360,31 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
return false;
}
/**
* Revives a dormant account, on the strength of a completed re-KYC.
*
* <p>
* RBI's Master Direction allows a dormant account to be operated again only once the customer's
* identity has been established afresh. A completed re-KYC is that verification, so it is the
* one thing that lifts dormancy — a transaction does not, which is why {@link #withdraw} refuses
* on a dormant account and a deposit no longer revives one.
*
* <p>
* Called from the re-KYC completion itself rather than from the nightly expiry sweep: the sweep
* runs over every customer whose KYC is merely current, and reviving there would reopen a
* dormant account on the next nightly run without anyone having verified anything.
*
* @return true if the sub-status was changed
*/
public boolean reviveOnReKyc() {
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
if (SavingsAccountSubStatusEnum.DORMANT.hasStateOf(current) || SavingsAccountSubStatusEnum.INACTIVE.hasStateOf(current)) {
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
return true;
}
return false;
}
public void validateForAccountBlock() {
final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus());
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) {
@@ -293,4 +293,52 @@
</sql>
</changeSet>
<!--
Clear the income GL these charges were seeded with, and let each product's own mapping decide.
Every insert above picks its income account with
SELECT id FROM acc_gl_account
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
ORDER BY id LIMIT 1
which is "whichever income account happens to have the lowest id". That is not a choice, it is an
accident of the order the chart of accounts was created in, and it lands somewhere different on
every database: on one tenant it resolved to 400101 INCOME FROM LOAN, so savings facility charges
were booked as loan income; on a tenant whose chart was seeded in another order it resolved to
400103 DD Issuance Charges Income, so an ATM charge was booked against demand drafts. Neither is
the account a bank would name for an ATM fee, and no screen shows the reader which one was picked.
A charge's own account overrides the product's Income from Fees mapping — see
AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges, which reads the charge's account
first and only falls back to the product mapping when it is null. So while these values stand, the
account an administrator chose on the product's Accounting tab is silently ignored. Setting them
null restores that mapping as the answer, which is where a bank configures this and the only place
it can be seen and changed.
Only rows still carrying the seeded value are touched: the account is compared against what that
same query resolves to now, so a charge an administrator has since pointed at a real account keeps
it. 0500_reset_facility_charge_mappings_to_unmapped.xml already decided these seeds are not an
administrator's choice and deactivated the mappings for the same reason.
-->
<changeSet id="2011-19" author="cbs-nitesh">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_charge"/>
</preConditions>
<sql>
UPDATE m_charge
SET income_or_liability_account_id = NULL
WHERE income_or_liability_account_id = (
SELECT id FROM acc_gl_account
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
ORDER BY id LIMIT 1)
AND name IN ('ATM Facility Charge', 'ATM Withdrawal Charge', 'UPI Facility Charge',
'POS Transaction Charge', 'Cheque Book Charge', 'IMPS Facility Charge',
'SMS Alert Monthly Charge', 'BBPS Facility Charge', 'Insurance Cover Charge',
'BEPG Facility Charge', 'NEFT Facility Charge', 'RTGS Facility Charge',
'Inter-Bank Cap Charge', 'Withdrawal Facility Charge', 'Deposit KYC Charge',
'Virtual Debit Card Charge');
</sql>
</changeSet>
</databaseChangeLog>