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
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant();
this.initiatedOn = DateUtils.getBusinessLocalDate();
}
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -562,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
// The four exception columns are derived from whether the identity checks have passed, not
// data an officer types, so they are updated without recording an overwrite. A verified
// record demands remarks for any field it already held (see
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
// counting these would make closing a settled exception impossible without a justification
// for something nobody chose. That is what left clients verified on the KYC tab while the
// exceptions register still listed them as Pending.
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
v -> this.isException = v != null ? v : false);
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
v -> this.exceptionReason = v);
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
v -> this.exceptionResolved = v != null ? v : false);
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
v -> this.exceptionResolvedOn = v);
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
@@ -620,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* populated is the record being completed rather than altered, so it is deliberately not
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
*/
/*
* Derived-field variants of the update helpers above.
*
* Identical, minus the recordOverwrite call: these columns are computed from whether the
* identity checks have passed rather than entered by an officer, so replacing one is not the
* kind of change a verified record demands a justification for. They still report into
* `changes`, so the update is persisted and audited like any other.
*/
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
if (command.parameterExists(param)) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue);
setter.accept(newValue);
}
}
}
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
}
}
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(newValue);
}
}
private void recordOverwrite(final String param, final Object currentValue) {
final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text)
@@ -670,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markCkycUploaded(final String ckycNumber, final String batchId) {
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
}
/** Called after successful 3rd-party verification of all required documents. */
public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = DateUtils.getLocalDateOfTenant();
this.verifiedOn = DateUtils.getBusinessLocalDate();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -709,21 +758,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return this.expiryDate;
}
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears());
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
return this.expiryDate;
}
/** Called when verification fails. */
public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue();
this.rejectedOn = DateUtils.getLocalDateOfTenant();
this.rejectedOn = DateUtils.getBusinessLocalDate();
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
}
public void markPanVerified(final String fullName, final String firstName, final String lastName,
final String aadhaarSeedingStatus, final String category, final String status) {
this.panVerified = true;
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -749,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() {
this.aadhaarVerified = true;
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markGstinVerified() {
this.gstinVerified = true;
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markCinVerified() {
this.cinVerified = true;
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified() {
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber;
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.isDirector = true;
this.dinMcaResponse = mcaResponseJson;
}
@@ -791,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else if (type != null && type.isNonIndividual()) {
allDone = this.panVerified && this.gstinVerified;
}
if (allDone && !getKycStatus().isVerified()) {
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
// periodic re-KYC is: an already-verified customer verified afresh.
//
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
// that now measures from it — frozen at the original onboarding. An officer who re-verified
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
// account still sitting on the inactive report, with nothing to say what was missing.
//
// Guarded on the verifications rather than the status, so this only fires when the checks
// that constitute a verification have actually been done again.
if (allDone) {
markVerified(this.ckycNumber);
}
return allDone;
@@ -93,6 +93,26 @@ public class ClientRiskRule {
@Column(name = "order_position", nullable = false)
private Integer orderPosition;
/**
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
*
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
* (Section 13).
*
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
* rule states what it is; nothing has to guess from the shape of its other columns.
*/
@Column(name = "rule_kind", length = 10)
private String ruleKind;
public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML";
protected ClientRiskRule() {}
/**
@@ -104,8 +124,9 @@ public class ClientRiskRule {
final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome,
final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus,
final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag,
final String dormantFlag) {
final String dormantFlag, final String ruleKind) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleKind = ruleKind;
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
@@ -137,6 +158,19 @@ public class ClientRiskRule {
return id;
}
public String getRuleKind() {
return ruleKind;
}
/**
* True when this rule may take part in onboarding risk categorisation. Rows predating the
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
* into the same table.
*/
public boolean isKycRule() {
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
}
public String getRuleName() {
return ruleName;
}
@@ -32,6 +32,7 @@ import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.AccrualAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForLoan;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForSavings;
import org.apache.fineract.accounting.common.AccountingConstants.CashAccountsForShares;
@@ -647,26 +648,39 @@ public class AccountingProcessorHelper {
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final GLAccount expenseAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId);
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
// The payable comes from the Interest Payable row on the product's Accounting tab, which is
// where a bank actually picks it. The reserved name in the product's other GL mappings is
// consulted only after that, for a product configured before that row was mapped, and the
// Savings Control last of all - interest owed is a liability either way.
final GLAccount payableAccount = Optional
.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)).orElse(controlAccount);
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
.orElse(controlAccount);
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
.orElse(payableAccount);
// Stage 1: the expense is incurred and parked in the payable.
postSavingsLeg(office, currencyCode, expenseAccount, payableAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// The interest expense is not raised here. It is recognised as the interest accrues, day by
// day, and this posting settles that recognised debt - raising the expense again at payout
// would put the same interest on the P&L twice. This mirrors the pooled posting service,
// which pays a product's whole membership the same way; a closure reaching this path must
// produce the same entries as the nightly run, or the two disagree on what one payment did.
//
// Stage 1: the liability moves from the payable to the account it is paid out of. Where the
// product names no paid account both resolve to the payable and this says nothing, so it is
// skipped and the payout comes straight out of the payable.
final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId());
if (!payableIsPaid) {
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
// Stage 2: the liability moves to the account it is paid out of.
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// Stage 3: into the member's own balance.
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
// Stage 2: into the member's own balance.
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
@@ -1380,6 +1394,29 @@ public class AccountingProcessorHelper {
return accountMapping.getGlAccount();
}
/**
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
*
* <p>
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
* required. Interest Payable is offered on the product screen but a product configured before it
* existed has none, and that has to fall through to the next choice rather than stopping the
* account from being closed.
*/
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) {
if (savingsProductId == null) {
return null;
}
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
// which returns null for a row the cash enum does not carry - Interest Payable among them -
// and the lookup would die on a NullPointerException instead of the exception meant for it.
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
return mapping == null ? null : mapping.getGlAccount();
}
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
final Long paymentTypeId) {
GLAccount glAccount;
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
.retrieveAllJobs(fineractProperties.getNodeId());
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
// Release any currently_running flag left over from before this boot.
//
// The flag is set in the scheduler's veto check before a job's body runs and cleared
// by the Quartz listener when it returns. Two things leave it set with nothing
// running: a JVM that stopped mid-run, and a job started through its own API rather
// than through Quartz — no listener is involved there, so nothing ever clears it.
//
// Once set, the veto silently drops every later trigger: no run-history row, no
// error, nothing in the log. The job reads as though it ran and did nothing, and one
// dead run mutes it for good. That is how a night of unpaid savings interest goes
// unnoticed.
//
// This loop is the platform coming up, so by definition none of these jobs is
// running: whatever the flag says, it is stale. scheduleJob() below already clears
// it on the paths it reaches, but not when it throws before that line, and the
// saveOrUpdate() after this loop persists whatever we set here either way.
jobDetails.setCurrentlyRunning(false);
try {
jobRegisterService.scheduleJob(jobDetails);
} catch (JobIsNotFoundOrNotEnabledException e) {
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -91,6 +93,7 @@ public class CashierDepositRequestApiResource {
private static final long PENDING_DEPOSIT_TXN_ID_OFFSET = 500_000_000L;
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -145,6 +148,7 @@ public class CashierDepositRequestApiResource {
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero");
}
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.CREDIT);
validateDenominationTotal(denominationData, amount);
validateDdLimit(ddFaceValue);
@@ -67,6 +67,7 @@ import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProces
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -82,6 +83,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils;
public class ClerkDisbursementRequestApiResource {
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -143,6 +145,14 @@ public class ClerkDisbursementRequestApiResource {
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each entry must have an account number");
}
// Checked by the direction of the leg. A Dr leg takes money out of the account and is
// stopped once the restriction ladder stops debits; a Cr leg pays money in and keeps
// working, since nothing about a restricted customer should prevent them being paid.
// A GL leg carries no savingsAccountId and the guard ignores it.
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
}
if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt);
} else {
@@ -54,6 +54,8 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.slf4j.Logger;
@@ -77,6 +79,7 @@ public class ClerkWithdrawalRequestApiResource {
private static final Logger LOG = LoggerFactory.getLogger(ClerkWithdrawalRequestApiResource.class);
private final JdbcTemplate jdbcTemplate;
private final KycRestrictionGuard kycRestrictionGuard;
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -121,6 +124,7 @@ public class ClerkWithdrawalRequestApiResource {
throw apiError(400, "Token number (TOK. NO.) is required");
}
validateTokenUniqueToday(tokNo, null);
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -453,6 +457,11 @@ public class ClerkWithdrawalRequestApiResource {
final Long savingsAccountId = record.get("savings_account_id") != null
? ((Number) record.get("savings_account_id")).longValue() : null;
// Checked again on approval, not only on submission: a request can sit in the queue for days,
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
// below refuses a dormant account anyway, but that failure would surface to the manager as a
// rejected transaction after the approval was recorded; this says why, before anything moves.
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date");
@@ -27,6 +27,10 @@ public final class KycRiskRuleData implements Serializable {
private final Long id;
private final String ruleName;
/** "KYC" (profile-driven, categorises at onboarding) or "AML" (transaction-monitoring
* scenario). The client risk calculator evaluates KYC rules only - an AML rule describes
* behaviour and may only fire against a posted transaction. */
private final String ruleKind;
private final Integer legalFormId;
private final Long occupationCategoryCvId;
private final String occupationCategoryName;
@@ -61,7 +65,7 @@ public final class KycRiskRuleData implements Serializable {
private final BigDecimal txnMinLimitAmount;
private final BigDecimal txnMaxLimitAmount;
public KycRiskRuleData(final Long id, final String ruleName, 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 occupationCvId, final String occupationName,
final BigDecimal minMonthlyIncome, final BigDecimal maxMonthlyIncome,
@@ -75,6 +79,7 @@ public final class KycRiskRuleData implements Serializable {
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount) {
this.id = id;
this.ruleName = ruleName;
this.ruleKind = ruleKind;
this.legalFormId = legalFormId;
this.occupationCategoryCvId = occupationCategoryCvId;
this.occupationCategoryName = occupationCategoryName;
@@ -111,6 +116,10 @@ public final class KycRiskRuleData implements Serializable {
return ruleName;
}
public String getRuleKind() {
return ruleKind;
}
public Integer getLegalFormId() {
return legalFormId;
}
@@ -40,6 +40,12 @@ public class KycRiskRuleRequest {
@Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED)
private String ruleName;
@Schema(description = "Which family the rule belongs to: KYC for a profile rule that categorises a customer at "
+ "onboarding from occupation / income / turnover, or AML for a transaction-monitoring scenario that may "
+ "only fire against posted transactions. Omit to have it inferred - supplying transaction settings, or "
+ "naming no profile criteria at all, makes it AML.", example = "KYC")
private String ruleKind;
@Schema(description = "Reason / criteria text explaining why the rule exists",
requiredMode = Schema.RequiredMode.REQUIRED)
private String reason;
@@ -52,6 +52,8 @@ import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTrans
import org.apache.fineract.portfolio.client.kyc.KycVerificationResult;
import org.apache.fineract.portfolio.client.kyc.KycVerificationService;
import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
@@ -73,6 +75,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
private final KycVerificationService kycVerificationService;
private final FromJsonHelper fromApiJsonHelper;
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
private final SavingsAccountRepository savingsAccountRepository;
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
@@ -414,8 +417,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
getJsonString(panData, "aadhaar_seeding_status"),
getJsonString(panData, "category"),
getJsonString(panData, "status"));
kyc.tryAutoVerify();
final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue());
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
@@ -458,8 +466,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
}
kyc.markAadhaarVerified();
kyc.tryAutoVerify();
final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("aadhaarVerified", true);
@@ -500,8 +513,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
}
kyc.markGstinVerified();
kyc.tryAutoVerify();
final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("gstinVerified", true);
@@ -563,8 +581,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
}
kyc.markCinVerified();
kyc.tryAutoVerify();
final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("cinVerified", true);
@@ -633,8 +656,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
final boolean companyNameMatched = matchedCompany != null;
kyc.markDinVerified(dinNumber, rawMcaResponse);
kyc.tryAutoVerify();
final boolean fullyVerified = kyc.tryAutoVerify();
kycRepositoryWrapper.saveAndFlush(kyc);
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
// same one the explicit command clears.
if (fullyVerified) {
reviveDormantAccounts(clientId);
}
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("dinNumber", dinNumber);
@@ -817,11 +845,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
kyc.markVerified(kyc.getCkycNumber());
kycRepositoryWrapper.saveAndFlush(kyc);
// A completed re-KYC is what revives the customer's dormant accounts.
//
// RBI's Master Direction allows a dormant account to be operated again only once the
// customer's identity has been established afresh, and this command is that establishment.
// Withdrawals are refused while an account is dormant and no transaction lifts it, so
// without this the account would stay frozen with no way out.
//
// Done here rather than in the nightly expiry sweep on purpose: that sweep visits every
// customer whose KYC is merely current, so reviving there would reopen a dormant account on
// the next run without anyone having verified anything.
final int revived = reviveDormantAccounts(clientId);
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
changes.put("verifiedOn", kyc.getVerifiedOn());
changes.put("expiryDate", kyc.getExpiryDate());
changes.put("kycStatus", kyc.getKycStatusValue());
changes.put("kycStatusCode", "kyc.status.verified");
if (revived > 0) {
changes.put("dormantAccountsRevived", revived);
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
}
return buildResult(command, client, clientId, kycId, changes);
}
@@ -1040,6 +1084,39 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
// ── helpers ───────────────────────────────────────────────────────────────
/**
* Clears the dormancy flag from this client's savings accounts, 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, so this is the one thing that lifts it — no transaction
* does, and a withdrawal is refused while the flag stands. Called from every path that
* completes a verification, not only the explicit complete-rekyc command: an officer who
* re-verifies PAN and Aadhaar at the counter has done the same verification and must not have
* to know about a second button.
*
* <p>
* Deliberately not called from the nightly expiry sweep, which visits every customer whose KYC
* is merely current — reviving there would reopen a dormant account on the next run without
* anyone having verified anything.
*
* @return how many accounts were revived
*/
private int reviveDormantAccounts(final Long clientId) {
int revived = 0;
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
if (account.reviveOnReKyc()) {
savingsAccountRepository.save(account);
revived++;
}
}
if (revived > 0) {
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
}
return revived;
}
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
final Long clientId, final Long kycId, final Map<String, Object> changes) {
return new CommandProcessingResultBuilder()
@@ -0,0 +1,201 @@
/**
* 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.client.service;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* What a customer with a lapsed periodic re-KYC may still do with their savings account.
*
* <p>
* One service because the answer has to be the same at every counter the money can leave by — the
* clerk's withdrawal request, the cashier's deposit, an account-to-account transfer, a transfer to
* or from a GL. Those are separate resources that each validate their own input, and a rule copied
* into four of them is a rule that will disagree with itself the first time one is edited.
*
* <h3>The ladder</h3>
*
* <table>
* <tr><th>Days overdue</th><th>Money out (withdrawal, transfer out)</th><th>Money in (deposit, transfer in)</th></tr>
* <tr><td>0&nbsp;–&nbsp;89 (grace)</td><td>allowed</td><td>allowed</td></tr>
* <tr><td>90&nbsp;–&nbsp;179</td><td>stopped</td><td>allowed</td></tr>
* <tr><td>180 and past</td><td>stopped</td><td>stopped</td></tr>
* </table>
*
* <p>
* Nothing at all is restricted during the grace window, and that is the point of it: RBI's 2021
* amendment is explicit that an account is not to be restricted merely because periodic KYC is
* pending, and that any restriction follows notice and a fair opportunity to comply. The customer
* is being chased, not shut out.
*
* <p>
* The line is drawn by direction, not by the counter the money crosses. Anything taking money out
* is stopped once debits stop — a transfer out is a withdrawal by another name — while anything
* paying money in keeps working, whether it is cash at the counter or a transfer from another
* account. A salary, a benefit, a relative sending funds: those must land, and none of them can
* move the balance anywhere while the debit side is shut.
*
* <h3>Why the days are counted rather than read off the account</h3>
*
* <p>
* The nightly job stamps the restriction on the savings account. Between a customer crossing a
* threshold and that job next running the account still reads as unrestricted, and a request
* raised in that window is accepted — the clerk is told it went through and the customer waits for
* money that was never going to be paid. Counting here closes that window, and matches the job so
* the two can never disagree.
*/
@Service
@RequiredArgsConstructor
public class KycRestrictionGuard {
private final JdbcTemplate jdbcTemplate;
/** What the caller is trying to do, which decides how far down the ladder it survives. */
public enum Operation {
/** Money out over the counter. Stopped once debits stop. */
DEBIT("Withdrawal"),
/** Money in, however it arrives. Allowed until the account is inoperative. */
CREDIT("Deposit"),
/** Money out to another account or a GL — a withdrawal by another name, so stopped with them. */
TRANSFER("Transfer");
private final String label;
Operation(final String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
/**
* Refuses the operation where the customer's re-KYC has lapsed far enough to stop it.
*
* <p>
* Silent for a null account, a client with no KYC record, and one whose KYC has no expiry date
* — none of those is a lapsed re-KYC, and refusing on absent data would block a counter over a
* record that was never set up.
*
* @throws PlatformDataIntegrityException naming the stage and what would lift it
*/
public void check(final Long savingsAccountId, final Operation operation) {
checkDormancy(savingsAccountId, operation);
checkKycExpiry(savingsAccountId, operation);
}
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
final Long daysOverdue = daysOverdue(savingsAccountId);
if (daysOverdue == null) {
return;
}
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
if (stage == null || stage.isGrace()) {
// Inside the grace window nothing is restricted; the customer is only being chased.
return;
}
// Past grace, a credit is the one thing still let through, until the account is inoperative.
if (operation == Operation.CREDIT && !stage.isInoperative()) {
return;
}
throw new PlatformDataIntegrityException("error.msg.client.kyc.lapsed." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The customer's periodic re-KYC lapsed " + daysOverdue
+ " days ago. Complete the re-KYC to lift this.");
}
/**
* Refuses money leaving, or moving, on an account nobody has operated for the product's
* dormancy period.
*
* <p>
* Checked alongside the re-KYC ladder because the two restrict for the same reason and are
* lifted by the same act: an account untouched for two years is the one an impersonator
* reaches for, and RBI allows it to be operated again only once the customer's identity has
* been established afresh. Deposits are left alone — money coming in cannot be a fraud against
* the customer — but a transfer moves the balance somewhere a debit block does not follow, so
* it is refused in either direction.
*
* <p>
* The days are counted from the same clock the ladder measures, so an account that crossed the
* threshold before the nightly job stamped it is caught all the same.
*/
private void checkDormancy(final Long savingsAccountId, final Operation operation) {
if (savingsAccountId == null || operation == Operation.CREDIT) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT sp.days_to_dormancy,
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
- GREATEST(sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date)) AS days_idle
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
""", savingsAccountId);
if (rows.isEmpty()) {
return;
}
final Number threshold = (Number) rows.get(0).get("days_to_dormancy");
final Number daysIdle = (Number) rows.get(0).get("days_idle");
if (threshold == null || daysIdle == null || daysIdle.longValue() < threshold.longValue()) {
return;
}
final long years = threshold.longValue() / 365;
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
}
/**
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
*
* <p>
* Measured against the business date, not the wall clock, so a branch working a backdated day
* sees the restrictions that applied on that day.
*/
public Long daysOverdue(final Long savingsAccountId) {
if (savingsAccountId == null) {
return null;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
FROM m_savings_account sa
JOIN m_client_kyc k ON k.client_id = sa.client_id
WHERE sa.id = ? AND k.expiry_date IS NOT NULL
ORDER BY k.expiry_date DESC
LIMIT 1
""", savingsAccountId);
if (rows.isEmpty()) {
return null;
}
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
}
}
@@ -66,6 +66,7 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
return new KycRiskRuleData(
rule.getId(),
rule.getRuleName(),
rule.isKycRule() ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML,
rule.getLegalFormId(),
occCatId,
occCatName,
@@ -59,6 +59,8 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
// inserting into the middle of the catalog would re-prioritise existing rules.
final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition();
final String ruleKind = resolveRuleKind(request);
final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(),
request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(),
request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(),
@@ -67,7 +69,7 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"),
StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"),
StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"),
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"));
StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE"), ruleKind);
if (request.getOccupationCategoryCvId() != null) {
final CodeValue occupationCategory = this.codeValueRepository
@@ -88,6 +90,44 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
return this.readPlatformService.retrieveRule(saved.getId());
}
/**
* Decides whether a newly authored rule takes part in onboarding risk categorisation.
*
* An explicit {@code ruleKind} from the caller wins. Otherwise the kind is inferred once, at
* authoring time, from what the request actually describes: a rule carrying live-transaction
* settings is a monitoring scenario, and so is a criteria-less rule - it names no occupation,
* no legal form and no income or turnover band, so as a KYC rule it would match every customer
* who ever walks in. That is precisely how "Additional RFIs Rule - 91" came to categorise a
* salaried employee as Medium Risk and flag them for AML.
*
* Inferring here rather than during evaluation is the point: the answer is written to the row
* and stays stable, so a later migration touching the criteria columns cannot silently change
* how an existing rule is treated.
*/
private String resolveRuleKind(final KycRiskRuleRequest request) {
if (StringUtils.isNotBlank(request.getRuleKind())) {
return ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim()) ? ClientRiskRule.KIND_AML
: ClientRiskRule.KIND_KYC;
}
if (request.hasTransactionRule()) {
return ClientRiskRule.KIND_AML;
}
return hasKycCriteria(request) ? ClientRiskRule.KIND_KYC : ClientRiskRule.KIND_AML;
}
/** A bound of zero is "not configured", the same reading the risk calculator applies. */
private boolean hasKycCriteria(final KycRiskRuleRequest request) {
return request.getLegalFormId() != null || request.getOccupationCategoryCvId() != null
|| request.getOccupationCvId() != null || isConfigured(request.getMinMonthlyIncome())
|| isConfigured(request.getMaxMonthlyIncome()) || isConfigured(request.getMinAnnualIncome())
|| isConfigured(request.getMaxAnnualIncome()) || isConfigured(request.getMinAnnualTurnover())
|| isConfigured(request.getMaxAnnualTurnover());
}
private boolean isConfigured(final BigDecimal amount) {
return amount != null && amount.compareTo(BigDecimal.ZERO) > 0;
}
private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) {
// The band's lower bound is what the rule fires at unless an explicit threshold is given,
// so a rule described purely as a band is still evaluable against live transactions.
@@ -114,6 +154,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100);
validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500);
validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3);
if (StringUtils.isNotBlank(request.getRuleKind()) && !ClientRiskRule.KIND_KYC.equalsIgnoreCase(request.getRuleKind().trim())
&& !ClientRiskRule.KIND_AML.equalsIgnoreCase(request.getRuleKind().trim())) {
validator.reset().parameter("ruleKind").value(request.getRuleKind()).failWithCode("must.be.kyc.or.aml");
}
validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull()
.integerGreaterThanZero();
validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull()
@@ -48,8 +48,19 @@ public class SavingsDailyInterestOrchestrator {
private final SavingsDailyInterestService accrualService;
private final SavingsPooledInterestPostingService postingService;
/** Accrue for the day, then post for any product whose cycle ends today. */
/**
* Accrue for the day, then post for any product whose posting date has arrived.
*
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
* posting then reads and updates those same rows in a transaction of its own
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
* another's payment). Holding an outer transaction across both would mean this thread still owns
* the row locks the accrual took while the posting's separate transaction waits for them — each
* waiting on the other, and the job hanging silently with nothing in the log. So each step
* commits on its own before the next begins.
*/
public void runForDate(final LocalDate businessDate) {
clearStaleRunningFlag();
accrualService.accrueForDate(businessDate);
enrolNewProducts();
@@ -73,18 +84,27 @@ public class SavingsDailyInterestOrchestrator {
for (final Map<String, Object> schedule : schedules) {
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
final Long dueCount = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_daily_interest
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
""", Long.class, productId, businessDate);
if (dueCount == null || dueCount == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
// Whether anything is due is decided inside postForProduct, not here.
//
// This used to pre-check with its own COUNT over m_savings_daily_interest, and that
// deadlocked the run: accrueForDate above is @Transactional and has just written every
// ledger row, so its transaction still holds them; the COUNT then read those rows on
// this thread's transaction, and postForProduct — which runs REQUIRES_NEW, in a
// transaction of its own — sat waiting for a lock this thread was holding and would only
// release after postForProduct returned. Each waited on the other and the job hung
// silently, with nothing in the log and the flag left saying it was still running.
//
// postForProduct already selects what is due and returns zero when there is nothing, so
// the pre-check bought nothing but the deadlock. Its selection is by posting_date rather
// than by the run date, which is what makes a missed posting date catch up on a later
// run instead of being skipped for good.
try {
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
if (paid.signum() == 0) {
updateSchedule(productId, businessDate, null);
continue;
}
updateSchedule(productId, businessDate, "COMPLETED");
log.info("Savings interest: product {} posted {} on {}", productId, paid, businessDate);
} catch (final RuntimeException e) {
@@ -112,6 +132,34 @@ public class SavingsDailyInterestOrchestrator {
}
}
/**
* Releases this job's own currently_running flag if an earlier run left it set.
*
* The platform sets that flag in the scheduler's veto check before a job's body runs, and clears
* it in the Quartz listener when the body returns. Two things leave it set with nothing running:
* a JVM stopped mid-run, and a run started through this service's own API rather than through
* Quartz — the listener is never involved there, so nothing ever clears it.
*
* Once set, the veto silently drops every later trigger: no run-history row, no error, nothing in
* the log. The job reads as though it ran and did nothing, which is exactly how a night of unpaid
* interest goes unnoticed. It happened here — a manual API run left the flag behind and the job
* was mute from then on.
*
* Reaching this line is itself the proof that no other run is in flight: Quartz will not start a
* second execution of a job it is already running, and this is the first statement of the only
* method that runs one. So the flag can only be a leftover, and clearing it means one dead or
* out-of-band run cannot mute the job for good.
*/
private void clearStaleRunningFlag() {
final int cleared = jdbcTemplate.update("""
UPDATE job SET currently_running = false
WHERE name = 'Savings Daily Interest Accrual And Posting' AND currently_running = true
""");
if (cleared > 0) {
log.warn("Savings interest: cleared a stale currently_running flag left by an earlier run");
}
}
/**
* Enrols the product behind one account, if it is not already on the timetable.
*
@@ -257,10 +257,31 @@ public class SavingsDailyInterestService {
cycleEnd = SavingsInterestCycleCalculator.cycleEndDate(businessDate, postingPeriod,
financialYearBeginningMonth);
} else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
cycleNumber = ((Number) prev.get("cycle_number")).intValue();
cycleStart = ((java.sql.Date) prev.get("cycle_start_date")).toLocalDate();
cycleEnd = prevCycleEnd;
// Days between the last row and today are earned days too, and have to be added.
//
// The ledger keeps one row per account, so the "previous" row is simply the last day
// the job ran for this account — not necessarily yesterday. Where nights were missed,
// or a business date was moved forward by more than a day, every day in between is a
// day the member held a balance and earned on it. Carrying the previous row's total
// forward unchanged and adding only today's interest silently drops all of them: an
// account activated on the 8th and accrued again on the 30th was credited two days'
// interest instead of twenty-three.
//
// So the gap is replayed from the account's own transactions, the same way a first
// row is built, and the result stands in for the running total the ledger would be
// holding had it run every night.
final LocalDate prevBalanceDate = ((java.sql.Date) prev.get("balance_date")).toLocalDate();
if (prevBalanceDate.plusDays(1).isBefore(businessDate)) {
final Backfill gap = backfillFromCycleStart(accountId, cycleStart, businessDate, rate, daysInYear,
minBalance, averageDailyBalanceOf(calcType));
carriedInterest = gap.cumulativeInterest();
} else {
carriedInterest = (BigDecimal) prev.get("cycle_cumulative_interest");
}
}
}
@@ -28,7 +28,6 @@ import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
/**
@@ -40,28 +39,41 @@ import org.springframework.transaction.annotation.Transactional;
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
* holds the figure that would say so.
*
* This posts the product's total first, into the liability the product names as its Savings Interest
* Payable, moves it to the account it is paid out of, and only then pays it to members:
* This settles the debt the accrual has already recognised: the product's total moves out of the
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
* and only then to the members:
*
* <pre>
* Dr Interest On Savings (expense, from the product)
* Cr Savings Interest Payable (liability, from the product) what the bank now owes
*
* Dr Savings Interest Payable
* Dr Savings Interest Payable (liability, from the product)
* Cr Savings Interest Paid on its way out
*
* Dr Savings Interest Paid (per member)
* Cr Savings Control (per member, from the product) in the member's hands
* </pre>
*
* Every account named above is read from the product's own GL configuration; none is written into
* this class. A product that names no paid account collapses the middle step - both legs resolve to
* the payable - and posts the two-legged entry it always did; one that names no payable falls back
* to its own Savings Control, since interest owed is a liability either way.
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
* this is the settlement of what that recognition already booked - raising the expense again at
* payout would put the same interest on the P&amp;L twice.
*
* The whole product settles inside one transaction, and the payable has to read zero before it is
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
* that never reached commit leaves a PENDING row naming the product and date for the next run.
* Every account named above is read from the product's own GL configuration; none is written into
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
* where that is unset, from the product's reserved GL mapping of the same name, and failing both
* from its Savings Control - interest owed is a liability either way.
*
* The first step is dropped where it would say nothing, and the payout then comes straight out of
* the payable:
*
* <pre>
* Dr Savings Interest Payable (per member)
* Cr Savings Control (per member)
* </pre>
*
* That happens when the product names no paid account, so both legs of the first step resolve to the
* payable and it would debit and credit one account for the same amount.
*
* The whole product settles inside one transaction. A failure rolls the product back entirely -
* nobody is half paid - and a run that never reached commit leaves a PENDING row naming the product
* and date for the next run.
*
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
@@ -92,20 +104,32 @@ public class SavingsPooledInterestPostingService {
private static final int INTEREST_POSTING = 3;
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
private static final int ENTITY_TYPE_SAVINGS = 2;
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
/**
* {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product's
* Accounting tab, which is where a bank picks the account interest is owed from.
*/
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
private final JdbcTemplate jdbcTemplate;
/**
* Posts one product's interest for one posting date.
*
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
* product's completed run down with it.
*
* @return the amount distributed, or zero if there was nothing to do
*
* <p>
* REQUIRED, not REQUIRES_NEW.
*
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
* the orchestrator calls this outside any transaction of its own, so each product opens and
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
* running and waits for locks that transaction may hold. The accrual step immediately before
* writes every account's ledger row, and this method reads and updates those same rows: with
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
* and the job hung with nothing in the log and its currently_running flag left set.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
@Transactional
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
final Long completed = jdbcTemplate.queryForObject("""
SELECT COUNT(*) FROM m_savings_interest_posting_run
@@ -116,8 +140,18 @@ public class SavingsPooledInterestPostingService {
return BigDecimal.ZERO;
}
// The accounts due today, with what they accrued. Anything already carrying a posting
// transaction is skipped, so a resumed run cannot pay somebody a second time.
// Every account whose posting date has arrived or passed and has not yet been paid.
//
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
// run on was never paid at all — the cycle simply rolled over with the interest carried into
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
// to behave when a night is missed.
//
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
// somebody a second time.
//
// A savings account has no office of its own: it belongs to a client, or to a group when
// there is no client, and the office is theirs. The journal entries have to be raised
// against that office or they land in the wrong branch's books.
@@ -130,7 +164,7 @@ public class SavingsPooledInterestPostingService {
JOIN m_savings_account sa ON sa.id = d.savings_account_id
LEFT JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_group g ON g.id = sa.group_id
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
WHERE d.savings_product_id = ? AND d.posting_date <= ?
AND d.savings_transaction_id IS NULL
AND d.cycle_cumulative_interest > 0
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
@@ -168,16 +202,23 @@ public class SavingsPooledInterestPostingService {
}
// The three accounts the bill passes through on its way to the members. Every one is read
// from the product's own configuration — the standard Interest On Savings mapping, and the
// two reserved names on the product's Accounting tab — so a bank on its own chart of
// accounts is posted to the accounts it chose. No GL code is named in this class.
// from the product's own configuration, so a bank on its own chart of accounts is posted to
// the accounts it chose. No GL code is named in this class.
//
// Where a product leaves a reserved name unset the bill still has somewhere to go: the
// payable falls back to the product's Savings Control (interest owed is a liability either
// way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and
// leaves the two-legged posting a product without these mappings has always had.
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
// The product's Accounting tab is the authority, and it is consulted first for all three:
// Interest On Savings for the expense, and the standard Interest Payable row for the
// payable. Those are the rows a bank actually fills in on the product screen.
//
// The reserved names in the product's other GL mappings are consulted only after that, as a
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
// which has no standard row of its own.
//
// Where a product sets none of them the bill still has somewhere to go: the payable falls
// back to the product's Savings Control (interest owed is a liability either way), and the
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
// posting a product without these mappings has always had.
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
@@ -185,24 +226,30 @@ public class SavingsPooledInterestPostingService {
final String currency = (String) due.get(0).get("currency_code");
final String poolReference = "SIP-" + productId + "-" + postingDate;
// Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's
// interest is parked in the payable, where it stands as a debt to the members until it
// reaches them.
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null);
// Stage 2: the debt moves to the account the payout is made from, so what is still merely
// owed and what is on its way out are two separate balances rather than one.
// Stage 1: the debt the accrual has been building moves out of the payable and into the
// account the payout is made from, so what is still merely owed and what is on its way to
// the members are two separate balances rather than one.
//
// The interest expense itself is not raised here. It is recognised as the interest accrues,
// day by day, and posting is the settlement of that recognised debt — raising the expense
// again at payout would count the same interest twice on the P&L.
//
// Skipped where the product names no separate paid account: both legs then resolve to the
// payable and this would debit and credit the same account for the same amount — a pair of
// entries that say nothing and clutter the ledger.
if (!payableGl.equals(paidGl)) {
// payable and this would debit and credit one account for the same amount, saying nothing.
final boolean payableIsPaid = payableGl.equals(paidGl);
final boolean stage1Applies = !payableIsPaid;
if (stage1Applies) {
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
}
// Stage 3: out to the members, one at a time.
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
// from there instead — debiting the paid account would take the money out of an account
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
// Stage 2: out to the members, one at a time.
BigDecimal distributed = BigDecimal.ZERO;
for (final Map<String, Object> row : due) {
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
@@ -217,7 +264,7 @@ public class SavingsPooledInterestPostingService {
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
newBalance);
journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference,
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
accountId);
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
accountId, transactionId);
@@ -232,12 +279,22 @@ public class SavingsPooledInterestPostingService {
WHERE id = ?
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
// Both interest columns move together, and that is not a duplicate.
//
// total_interest_earned_derived is the interest the account has earned to date;
// total_interest_posted_derived is how much of that has been paid into the balance.
// Screens and validate()'s own check read the unposted interest as earned - posted, so
// raising only `posted` drives that difference negative: an account paid 26.52 with
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
// showed exactly that. The accrual writes pending interest to the ledger and never to
// `earned`, so this posting is the only place `earned` is realised.
jdbcTemplate.update("""
UPDATE m_savings_account
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
WHERE id = ?
""", amount, amount, accountId);
""", amount, amount, amount, accountId);
distributed = distributed.add(amount);
}
@@ -297,7 +354,7 @@ public class SavingsPooledInterestPostingService {
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
final long transactionId) {
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
}
@@ -306,6 +363,18 @@ public class SavingsPooledInterestPostingService {
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
}
/**
* One journal entry leg.
*
* <p>
* Fineract convention: {@code type_enum} 2 = DEBIT, 1 = CREDIT — {@code JournalEntryType}
* declares {@code CREDIT(1)} first. This read {@code debit ? 1 : 2} and so signed every leg the
* wrong way round: the member's savings-control credit was stored as a debit and the expense
* debit as a credit. Nothing downstream caught it — the run's own pooled-equals-distributed check
* compares Java values and never looks at {@code type_enum}, and migration 0531 disables the
* stock posting job, so this is the only path writing savings interest to the ledger and there
* was no correct one alongside to disagree with it.
*/
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
final Long transactionId) {
@@ -318,13 +387,6 @@ public class SavingsPooledInterestPostingService {
created_on_utc, last_modified_on_utc, submitted_on_date)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares
// CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way
// round: the member's savings-control credit was stored as a debit and the expense
// debit as a credit. Nothing downstream caught it — the run's own pooled-equals-
// distributed check compares Java values and never looks at type_enum, and migration
// 0531 disables the stock posting job, so this is the only path writing savings
// interest to the ledger and there was no correct one alongside to disagree with it.
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
debit ? "Savings interest posting" : "Savings interest posted",
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
@@ -351,16 +413,29 @@ public class SavingsPooledInterestPostingService {
* products — the message has to say which product to go and configure.
*/
private Long mappedGlAccount(final long productId, final int financialAccountType) {
final Long found = optionalMappedGlAccount(productId, financialAccountType);
if (found == null) {
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
}
return found;
}
/**
* One row of the product's Accounting tab, or null where the bank has not filled it in.
*
* <p>
* Separate from {@link #mappedGlAccount} because not every row is required: Interest Payable is
* offered on the product screen but a product configured before it existed has none, and that
* must fall through to the next choice rather than stopping the product from being paid.
*/
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl_account_id FROM acc_product_mapping
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
LIMIT 1
""", Long.class, productId, financialAccountType);
if (found.isEmpty()) {
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
}
return found.get(0);
return found.isEmpty() ? null : found.get(0);
}
/**
@@ -403,7 +478,13 @@ public class SavingsPooledInterestPostingService {
return found.isEmpty() ? null : found.get(0);
}
private static Long firstNonNull(final Long preferred, final Long fallback) {
return preferred != null ? preferred : fallback;
/** The first configured account of those given, in order of preference. */
private static Long firstNonNull(final Long... candidates) {
for (final Long candidate : candidates) {
if (candidate != null) {
return candidate;
}
}
return null;
}
}
@@ -31,6 +31,7 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
@@ -290,16 +291,33 @@ public class SavingsDailyInterestApiResource {
return gson.toJson(result);
}
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
/**
* Runs a day by hand, for a first backfill or after fixing a failed night.
*
* Defaults to the business date, and refuses to run past it. Accruing or posting for a date the
* bank has not reached writes interest into the books for days that have not happened: the
* member's balance goes up today for a quarter that ends next month, and the journal entries
* carry that future date. Worse, the business date can be moved back afterwards — for a test, or
* because it was advanced by mistake — and then the ledger holds a posting dated beyond the day
* the bank is working, which nothing reconciles against.
*
* To post for a later date, move the business date there first. That is the same order the
* nightly run follows, and it keeps the books and the business date in step.
*/
@POST
@Path("/run")
public String run(@QueryParam("date") final String dateParam) {
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
: LocalDate.parse(dateParam);
orchestrator.runForDate(businessDate);
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final LocalDate runFor = dateParam == null || dateParam.isBlank() ? businessDate : LocalDate.parse(dateParam);
if (runFor.isAfter(businessDate)) {
throw new PlatformDataIntegrityException("error.msg.savings.interest.run.after.business.date",
"Cannot run savings interest for " + runFor + ": the business date is " + businessDate
+ ". Move the business date forward first — interest cannot be accrued or posted for a day"
+ " the bank has not reached.");
}
orchestrator.runForDate(runFor);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("ranFor", businessDate.toString());
result.put("ranFor", runFor.toString());
return gson.toJson(result);
}
@@ -44,6 +44,7 @@ import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -61,6 +62,7 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
private final ConfigurationDomainService configurationDomainService;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final BusinessEventNotifierService businessEventNotifierService;
private final KycRestrictionGuard kycRestrictionGuard;
private final SavingsDailyInterestService savingsDailyInterestService;
private final VoucherService voucherService;
@@ -72,7 +74,8 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
final ConfigurationDomainService configurationDomainService, final PlatformSecurityContext context,
final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
final BusinessEventNotifierService businessEventNotifierService,
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService) {
final SavingsDailyInterestService savingsDailyInterestService, final VoucherService voucherService,
final KycRestrictionGuard kycRestrictionGuard) {
this.savingsAccountRepository = savingsAccountRepository;
this.savingsAccountTransactionRepository = savingsAccountTransactionRepository;
this.applicationCurrencyRepositoryWrapper = applicationCurrencyRepositoryWrapper;
@@ -83,6 +86,7 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
this.businessEventNotifierService = businessEventNotifierService;
this.savingsDailyInterestService = savingsDailyInterestService;
this.voucherService = voucherService;
this.kycRestrictionGuard = kycRestrictionGuard;
}
@Transactional
@@ -93,6 +97,10 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
context.authenticatedUser();
account.validateForAccountBlock();
account.validateForDebitBlock();
// Every withdrawal in the system arrives here — the clerk's counter, the core API, an
// account transfer, a standing instruction — so the dormancy and re-KYC ladders are
// enforced at this one point rather than at each of those doors.
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.DEBIT);
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Long relaxingDaysConfigForPivotDate = this.configurationDomainService.retrieveRelaxingDaysConfigForPivotDate();
@@ -206,6 +214,12 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi
context.authenticatedUser();
account.validateForAccountBlock();
account.validateForCreditBlock();
// Money coming in is a credit however it arrives — over the counter, or transferred from
// another account. The restriction is on the customer taking money out or moving it away,
// not on anyone paying them: a salary credit, a benefit transfer or a relative sending
// funds must land while the customer is being chased for their re-KYC, and it cannot be
// used to spirit the balance anywhere, since the debit side stays shut.
kycRestrictionGuard.check(account.getId(), KycRestrictionGuard.Operation.CREDIT);
// Global configurations
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
@@ -0,0 +1,63 @@
/**
* 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;
/**
* The one definition of when a savings account was last known to be in live use, for the dormancy
* ladder to measure from.
*
* <p>
* It is written here once because it is read from six places — the nightly job's three selection
* queries, the account read used by the counter screen, the account list, and the dormancy report
* — and those places disagreeing is what makes an account inactive on one screen and active on the
* next. A change to the rule belongs in this file and nowhere else.
*
* <h3>What counts</h3>
*
* <p>
* A completed re-KYC, and nothing else. RBI's Master Direction on KYC makes periodic re-verification
* the thing that keeps an account operative, and a lapsed account operable again only once the
* customer's identity has been established afresh; a cash deposit at the counter is not that
* verification, so it no longer revives an account here.
*
* <p>
* The account's activation stands in for a client who has never been re-verified, so a newly opened
* account does not start life a year behind.
*/
public final class DormancyClock {
private DormancyClock() {}
/**
* SQL for the date the dormancy ladder measures from, as an expression over the aliases
* {@code sa} (m_savings_account) and {@code c} (m_client).
*
* <p>
* Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a
* WHERE clause without having to add a join it may already have — and so a client with no KYC
* record at all falls back to the activation date rather than dropping out of the result.
*/
public static final String LAST_LIVE_ON = """
GREATEST(
sa.activatedon_date,
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
sa.activatedon_date))
""";
}
@@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
this.context.authenticatedUser();
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) "
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
final StringBuilder sql = new StringBuilder();
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
@@ -85,19 +83,57 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
// and the absence of one is exactly what the report has to show.
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
// Only the three states the ladder sets. A blocked or a plainly active account is not
// dormant and does not belong on these reports.
sql.append("where sa.sub_status_enum in (?, ?, ?) ");
// An account belongs on these reports once the ladder has stamped it, or once the days
// have earned it — whichever comes first.
//
// The stamp is applied by the nightly job, so an account that crossed a threshold today
// still carries sub_status 0 until that job next runs, and the report showed nothing at
// all: an account 23 days past its dormancy date was absent from a report whose whole
// purpose is to list it. The elapsed count is already computed above for the daysIdle
// column and uses the job's own definition of a customer transaction, so reading the
// thresholds off the same product row here cannot disagree with what the job will do.
//
// Escheat is matched on the stamp alone. Unlike the other two it is not a label but a
// movement of money — the job closes the account and takes the balance out — and listing
// an account as escheated before that happened would report money as remitted that is
// still sitting in the account.
sql.append("where (sa.sub_status_enum in (?, ?, ?) ");
sql.append(" or (sp.is_dormancy_tracking_active = true and sa.status_enum = 300 ");
sql.append(" and (sp.days_to_inactive > 0 and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" >= sp.days_to_inactive))) ");
final List<Object> params = new ArrayList<>();
params.add(DateUtils.getBusinessLocalDate());
params.add(SUB_STATUS_INACTIVE);
params.add(SUB_STATUS_DORMANT);
params.add(SUB_STATUS_ESCHEAT);
params.add(DateUtils.getBusinessLocalDate());
if (subStatus != null) {
sql.append("and sa.sub_status_enum = ? ");
params.add(subStatus);
// Match the stamp or the stage the elapsed days have earned, so a stage filter picks up
// the same accounts the unfiltered report shows.
if (subStatus == SUB_STATUS_ESCHEAT) {
sql.append("and sa.sub_status_enum = ? ");
params.add(subStatus);
} else if (subStatus == SUB_STATUS_DORMANT) {
// Past the escheat threshold but not yet escheated still counts as dormant: the
// account is dormant until the job closes it and moves the money, and it has to
// appear somewhere until then.
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_dormancy > 0 and ")
.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" >= sp.days_to_dormancy)) ");
params.add(subStatus);
params.add(SUB_STATUS_ESCHEAT);
params.add(DateUtils.getBusinessLocalDate());
} else {
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_inactive > 0 and ")
.append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" >= sp.days_to_inactive and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
.append(" < sp.days_to_dormancy)) ");
params.add(subStatus);
params.add(SUB_STATUS_DORMANT);
params.add(DateUtils.getBusinessLocalDate());
params.add(DateUtils.getBusinessLocalDate());
}
}
if (officeName != null && !officeName.isBlank()) {
sql.append("and o.name = ? ");
@@ -114,9 +150,19 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
@Override
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
final int subStatus = rs.getInt("subStatus");
final BigDecimal balance = rs.getBigDecimal("balance");
final long daysIdle = rs.getLong("daysIdle");
// The stage the elapsed days put the account at, in place of the stamp — the same rule
// the WHERE clause selects on, so a row cannot appear on the report under one stage and
// be labelled another.
//
// The days replace the stamp rather than merely raising it, because the stamp can also
// be ahead: a customer transaction resets the clock, and an escheat that has not
// happened yet leaves an account reported as dormant, which is what it still is.
// Escheat itself is the exception and keeps its stamp, since the job has by then closed
// the account and moved the balance out.
final int stamped = rs.getInt("subStatus");
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
@@ -134,6 +180,27 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
}
/**
* The stage the elapsed days have earned, whatever the job has stamped so far.
*
* <p>
* Escheat is never earned this way. Unlike the other two it is not a label but a movement
* of money — the job closes the account and takes the balance out — so an account only
* counts as escheated once that has actually happened, and until then it is reported as
* dormant, which is what it still is.
*/
private int earnedStage(final ResultSet rs, final long daysIdle) throws SQLException {
final Long toDormancy = nullableLong(rs, "daysToDormancy");
if (toDormancy != null && toDormancy > 0 && daysIdle >= toDormancy) {
return SUB_STATUS_DORMANT;
}
final Long toInactive = nullableLong(rs, "daysToInactive");
if (toInactive != null && toInactive > 0 && daysIdle >= toInactive) {
return SUB_STATUS_INACTIVE;
}
return 0;
}
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
final long value = rs.getLong(column);
return rs.wasNull() ? null : value;
@@ -22,9 +22,11 @@ import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsCons
import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD;
import java.math.BigDecimal;
import lombok.extern.slf4j.Slf4j;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
@@ -66,6 +68,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
@@ -83,6 +86,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.ResultSetExtractor;
import org.springframework.jdbc.core.RowMapper;
@Slf4j
public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService {
private final PlatformSecurityContext context;
@@ -104,14 +108,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
private final SavingsAccountAssembler savingAccountAssembler;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
private final SavingsDailyInterestService savingsDailyInterestService;
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
final SavingsDailyInterestService savingsDailyInterestService) {
this.context = context;
this.jdbcTemplate = jdbcTemplate;
this.sqlGenerator = sqlGenerator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
this.savingsDailyInterestService = savingsDailyInterestService;
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
this.transactionsMapper = new SavingsAccountTransactionsMapper();
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
@@ -122,6 +129,98 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
this.savingAccountAssembler = savingAccountAssembler;
}
/**
* Re-accrues one account's interest ledger if it is not already at the business date.
*
* <p>
* Guarded by a cheap read so the common case costs one indexed lookup and no write: through a
* normal day the nightly run has already brought the row to today and there is nothing to do. The
* refresh only fires when the row is genuinely behind — after the business date moves, or on an
* account whose row the nightly run has not reached yet.
*
* <p>
* A row dated <em>after</em> the business date is refreshed too, not skipped. That is the case
* where the business date has been moved back, and the ledger has to come down to the earlier
* date just as it goes up to a later one.
*
* <p>
* Only savings accounts carry a row here; fixed and recurring deposits accrue through core
* Fineract and are left alone by the refresh itself.
*/
/**
* The dormancy sub-status the elapsed days have earned, whatever the column currently says.
*
* <p>
* The nightly job is what stamps the column, so between an account crossing a threshold and
* that job next running the column understates where the account actually stands — by a day in
* the ordinary case, by longer after an outage or when the business date moves by more than a
* day. Screens that read the column alone then tell the counter an account is fine when the
* transaction is about to be refused.
*
* <p>
* Mirrors the job's own thresholds, which are the product's, and is only ever used to raise the
* reported status, never to lower one the job has already set.
*/
/**
* The dormancy sub-status to report: the one the elapsed days say, in place of the one the
* nightly job last stamped.
*
* <p>
* Both directions, not only upward. Upward closes the gap between an account crossing a
* threshold and the job next running. Downward matters because the stamp can outlive the
* reason for it: a customer transaction resets the clock, and moving the business date back —
* which is how this is tested — leaves a DORMANT stamp on an account only 379 days idle, which
* is inactive. Either way the days are the truth and the stamp is a cache of it.
*
* <p>
* Only the three dormancy states are touched. A blocked account, or one the job has escheated,
* keeps what it has: those are not positions on this ladder, and escheat in particular has
* moved money and closed the account, which no recomputation here can undo.
*/
private static int reportedDormancy(final Integer stamped, final LocalDate lastActive, final LocalDate businessDate,
final Integer daysToInactive, final Integer daysToDormancy) {
final int current = stamped == null ? SavingsAccountSubStatusEnum.NONE.getValue() : stamped;
if (current >= SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
return current;
}
return earnedSubStatus(lastActive, businessDate, daysToInactive, daysToDormancy);
}
private static int earnedSubStatus(final LocalDate lastActive, final LocalDate businessDate, final Integer daysToInactive,
final Integer daysToDormancy) {
if (lastActive == null) {
return SavingsAccountSubStatusEnum.NONE.getValue();
}
final long idle = ChronoUnit.DAYS.between(lastActive, businessDate);
// Escheat is deliberately not anticipated. Unlike the other two it is not a label but a
// movement of money — the job closes the account and transfers the balance to the escheat
// liability — and reporting it before that has happened would show a closed account still
// holding its balance. It stays whatever the job has actually done.
if (daysToDormancy != null && daysToDormancy > 0 && idle >= daysToDormancy) {
return SavingsAccountSubStatusEnum.DORMANT.getValue();
}
if (daysToInactive != null && daysToInactive > 0 && idle >= daysToInactive) {
return SavingsAccountSubStatusEnum.INACTIVE.getValue();
}
return SavingsAccountSubStatusEnum.NONE.getValue();
}
private void refreshInterestLedger(final Long accountId) {
try {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<LocalDate> ledgerDate = this.jdbcTemplate.queryForList(
"select balance_date from m_savings_daily_interest where savings_account_id = ?", LocalDate.class, accountId);
if (!ledgerDate.isEmpty() && DateUtils.isEqual(ledgerDate.get(0), businessDate)) {
return;
}
this.savingsDailyInterestService.refreshAfterBalanceChange(accountId, businessDate);
} catch (final RuntimeException e) {
// Never break the read. The account must still open if its ledger cannot be refreshed;
// the nightly run will pick it up.
log.warn("Could not refresh the interest ledger for savings account {} before reading it", accountId, e);
}
}
@Override
public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) {
@@ -269,6 +368,21 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
@Override
public SavingsAccountData retrieveOne(final Long accountId) {
// Bring the interest ledger up to the business date before reading it.
//
// The accrual normally runs once a night, so between the business date moving and the next
// run the ledger is behind and this screen reports interest as of the older date. Moving the
// business date to 30 September and opening the account showed the interest accrued to the
// 9th, and moving it back showed the higher figure — the interest did not follow the date in
// either direction, which is what made it look broken.
//
// refreshAfterBalanceChange is built for exactly this: it rewrites the account's row from the
// day before it rather than from itself, so calling it repeatedly converges instead of
// compounding, it creates the row if the account has none, and it never propagates a failure
// to its caller. That last property is what makes it safe on a read path — a ledger that
// cannot be refreshed must not stop the account from being displayed.
refreshInterestLedger(accountId);
try {
final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?";
@@ -379,7 +493,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
// Interest earned counts what the daily ledger has accrued but not yet paid.
//
// total_interest_earned_derived only moves when interest is posted, so between two
// posting dates it says the account has earned nothing since the last payout. The screen
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
// (or a stale figure) on accounts the accrual had been building up for weeks — one
// account had accrued 1,024.11 while the screen read 16.29.
//
// m_savings_daily_interest holds one row per account carrying the interest accrued in
// the current cycle, which is exactly the unposted amount. Adding it to what has been
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
// reading their own column exactly as before.
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
// Adding the cumulative figure whole would count that payment twice and report the
// account as having earned 24.90.
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
+ "as totalInterestEarned, ");
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
@@ -397,11 +532,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
sqlBuilder.append("tg.id as taxGroupId, ");
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
sqlBuilder.append("from m_savings_account_transaction as sat ");
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
sqlBuilder.append("sp.id as productId, ");
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
@@ -521,8 +652,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive");
final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive");
@@ -534,6 +664,20 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate currentDate = DateUtils.getBusinessLocalDate();
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
// Report the sub-status the elapsed days have earned, not only the one the
// nightly job has got round to stamping.
//
// The job stamps DORMANT at midnight, so an account that crossed the
// threshold during the day still reads as active until the next run — and
// that is what the counter screen showed the clerk: "Status: Active", no
// dormancy banner, on an account 23 days past its dormancy date. The clerk
// filled in the whole slip before anything refused it.
//
// The thresholds and the last-active date are already read above for the
// countdown, so this only reuses them. It is a read: the account row is not
// written here, and the job remains what actually stamps the column.
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
numDaysToDormancy);
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate,
lastActiveTransactionDate.plusDays(numDaysToEscheat));
@@ -547,6 +691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
lastActiveTransactionDate.plusDays(numDaysToInactive));
}
}
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate");
final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate");
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
@@ -596,7 +741,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
BigDecimal interestNotPosted = BigDecimal.ZERO;
LocalDate lastInterestCalculationDate = null;
if (totalInterestEarned != null) {
// Floored at zero: this is unposted interest, and it cannot be negative.
//
// Earned and posted are two separate columns that a failed or reverted
// posting can leave inconsistent, and the difference going negative used to
// surface on the account screen as "Overdraft Interest Not Posted -25.98" —
// the screen shows this same field under that label whenever it is negative,
// on accounts with no overdraft at all.
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
interestNotPosted = BigDecimal.ZERO;
}
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
}
@@ -830,7 +985,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
// Interest earned counts what the daily ledger has accrued but not yet paid.
//
// total_interest_earned_derived only moves when interest is posted, so between two
// posting dates it says the account has earned nothing since the last payout. The screen
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
// (or a stale figure) on accounts the accrual had been building up for weeks — one
// account had accrued 1,024.11 while the screen read 16.29.
//
// m_savings_daily_interest holds one row per account carrying the interest accrued in
// the current cycle, which is exactly the unposted amount. Adding it to what has been
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
// reading their own column exactly as before.
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
// Adding the cumulative figure whole would count that payment twice and report the
// account as having earned 24.90.
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
+ "as totalInterestEarned, ");
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
@@ -849,11 +1025,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, ");
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
sqlBuilder.append("from m_savings_account_transaction as sat ");
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, ");
@@ -948,8 +1120,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
final String reasonForBlock = rs.getString("reasonForBlock");
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
@@ -963,6 +1134,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate currentDate = DateUtils.getBusinessLocalDate();
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
// The elapsed days decide, not only the column the nightly job stamps — see
// earnedSubStatus. This is the mapper behind the single-account read, which is what
// the counter screen loads when a clerk searches an account, so without this it
// showed "Active" on an account already past its dormancy date and the clerk filled
// in the whole slip before anything refused it.
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
numDaysToDormancy);
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat));
}
@@ -973,6 +1151,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive));
}
}
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
final String submittedByUsername = rs.getString("submittedByUsername");
@@ -1092,7 +1271,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
BigDecimal interestNotPosted = BigDecimal.ZERO;
LocalDate lastInterestCalculationDate = null;
if (totalInterestEarned != null) {
// Floored at zero, for the same reason as the summary mapper above: unposted
// interest cannot be negative, and a negative value reaches the account screen
// labelled "Overdraft Interest Not Posted" even on accounts with no overdraft.
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
interestNotPosted = BigDecimal.ZERO;
}
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
}
@@ -1484,10 +1669,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) ");
sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 0 ");
String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) "
+ "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false"
+ " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)";
sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive ");
sql.append(" and ").append(sqlGenerator.dateDiff("?", DormancyClock.LAST_LIVE_ON)).append(" >= sp.days_to_inactive ");
try {
ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class);
@@ -1509,7 +1691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 100 ");
sql.append(" and " + sqlGenerator.dateDiff("?",
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
DormancyClock.LAST_LIVE_ON)
+ " ");
sql.append(" >= sp.days_to_dormancy ");
@@ -1533,7 +1715,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sql.append(" where sa.status_enum = 300 ");
sql.append(" and sa.sub_status_enum = 200 ");
sql.append(" and " + sqlGenerator.dateDiff("?",
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
DormancyClock.LAST_LIVE_ON)
+ " ");
sql.append(" >= sp.days_to_escheat ");
@@ -183,6 +183,7 @@ import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.context.annotation.Scope;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -426,9 +427,12 @@ public class SavingsConfiguration {
@ConditionalOnMissingBean(SavingsAccountReadPlatformService.class)
public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator,
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator) {
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
// @Lazy because the accrual service depends on beans from this same package: resolving
// both eagerly closes a cycle and the context fails to start.
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
sqlGenerator, savingsAccountRepositoryWrapper);
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
}
@Bean
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -611,4 +611,5 @@
<include file="parts/0533_backfill_savings_daily_interest_from_cycle_start.xml" relativeToChangelogFile="true"/>
<include file="parts/0534_enable_business_date_advance_jobs.xml" relativeToChangelogFile="true"/>
<include file="parts/0535_sync_business_date_to_system_date_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0538_separate_kyc_and_aml_risk_rules.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -52,4 +52,31 @@
</insert>
</changeSet>
<!--
Move the identity counter past the id inserted above.
job.id is a Postgres identity column (GENERATED BY DEFAULT), so supplying an explicit id
writes the row without advancing the sequence behind it. The counter is left at whatever it
reached on its own, and the next insert that lets the identity assign an id then collides
with this row: on a brand-new tenant the counter sits at 50 and the savings module's own
"Add Accrual Transactions For Savings" seed (fineract-savings module, 2001, which correctly
omits the id) fails with a duplicate key on job_pkey. One failing tenant aborts startup for
every tenant.
An existing database never hit it because that job was seeded long before this one, so the
counter had already moved past 50 by other means. Setting the counter from the table's own
maximum is idempotent and safe to run against either.
-->
<changeSet id="0457_advance_job_identity_sequence" author="CBS">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM information_schema.columns
WHERE table_name = 'job' AND column_name = 'id' AND is_identity = 'YES'
</sqlCheck>
</preConditions>
<sql>
SELECT setval(pg_get_serial_sequence('job', 'id'), (SELECT MAX(id) FROM job))
</sql>
</changeSet>
</databaseChangeLog>
@@ -236,4 +236,61 @@
referencedTableName="m_savings_interest_posting_run" referencedColumnNames="id"/>
</changeSet>
<!--
Bring the table to the one-row-per-account shape 0527 defines, for a database that has just
created it here.
0527 replaces the (account, date) constraint above with one on account alone, and the service
depends on it: SavingsDailyInterestService upserts with `ON CONFLICT (savings_account_id)`,
which Postgres rejects outright — "there is no unique or exclusion constraint matching the ON
CONFLICT specification" — when that exact constraint is absent. Every deposit, activation and
accrual on the account fails with it.
0527 cannot do this itself on a new tenant: it is registered in changelog-tenant.xml, which
db.changelog-master includes before the modules and before final-changelog-tenant.xml, where
this file lives. So on a fresh database 0527 runs before the table exists and marks itself
run, and the shape it was meant to apply never arrives. Applying it here, immediately after
the table is created, is the only ordering that works for both cases: an existing database
already has the constraint from 0527 and these changeSets mark themselves run.
-->
<changeSet id="0517_collapse_daily_interest_to_one_row_per_account" author="CBS">
<preConditions onFail="MARK_RAN">
<and>
<tableExists tableName="m_savings_daily_interest"/>
<not><indexExists indexName="uq_savings_daily_interest_account"/></not>
</and>
</preConditions>
<!-- Keep the latest day per account, which is the row carrying the running cycle total. -->
<sql>
DELETE FROM m_savings_daily_interest d
WHERE d.balance_date &lt; (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
WHERE x.savings_account_id = d.savings_account_id)
</sql>
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
constraintName="uq_savings_daily_interest_account"/>
</changeSet>
<changeSet id="0517_drop_daily_interest_account_date_constraint" author="CBS">
<preConditions onFail="MARK_RAN">
<indexExists indexName="uq_savings_daily_interest_account_date"/>
</preConditions>
<dropUniqueConstraint tableName="m_savings_daily_interest"
constraintName="uq_savings_daily_interest_account_date"/>
</changeSet>
<!-- With one row per account the date adds nothing to the product index; the posting query
looks up by product alone. -->
<changeSet id="0517_replace_product_date_index" author="CBS">
<preConditions onFail="MARK_RAN">
<and>
<indexExists indexName="idx_sdi_product_date"/>
<not><indexExists indexName="idx_sdi_product"/></not>
</and>
</preConditions>
<dropIndex indexName="idx_sdi_product_date" tableName="m_savings_daily_interest"/>
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
<column name="savings_product_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -59,6 +59,19 @@
unposted accruals are picked up on the first run rather than the second.
-->
<changeSet author="fineract" id="2">
<!--
Skipped when the table does not exist yet, which is the case on a brand-new database.
0518 creates it, but 0518 lives in final-changelog-tenant.xml, which db.changelog-master
includes AFTER changelog-tenant.xml and all the modules — so on a fresh tenant this
insert runs before the table is created and fails, and one failing tenant aborts startup
for every tenant. On an existing database the table is already there and this seeds it as
before. Nothing is lost by skipping: the orchestrator enrols any product missing from the
schedule on its next run, which is what the comment above describes.
-->
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_interest_posting_schedule"/>
</preConditions>
<sql>
INSERT INTO m_savings_interest_posting_schedule
(savings_product_id, posting_period_enum, run_at_time, is_active)
@@ -38,6 +38,19 @@
start, so the day after a posting it begins again at that day's interest.
-->
<changeSet author="fineract" id="1">
<!--
Skipped until the ledger table exists.
0517 creates m_savings_daily_interest, but it lives in final-changelog-tenant.xml, which
db.changelog-master includes after changelog-tenant.xml and all the modules. So on a
brand-new tenant this view is defined before the table it reads, and the failure aborts
startup for every tenant, not just the new one. On an existing database the table is
already there and the view is created as before; on a fresh one it is picked up the next
time migrations run, once final-changelog has created the table.
-->
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
</preConditions>
<createView viewName="v_savings_interest_summary" replaceIfExists="true">
SELECT
d.savings_account_id AS savings_account_id,
@@ -60,6 +60,16 @@
created. The latest day is kept: it is the one carrying the current cycle's running total.
-->
<changeSet author="fineract" id="2">
<!--
Skipped until the table exists. 0517 creates it, but 0517 lives in
final-changelog-tenant.xml which db.changelog-master includes after this changelog and
all the modules — so on a brand-new tenant every changeSet here runs before the table
does, and the failure aborts startup for every tenant rather than just the new one.
Nothing is lost by skipping: a table created later is created with these shapes already.
-->
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
</preConditions>
<sql>
DELETE FROM m_savings_daily_interest d
WHERE d.balance_date &lt; (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
@@ -68,6 +78,9 @@
</changeSet>
<changeSet author="fineract" id="3">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
</preConditions>
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
constraintName="uq_savings_daily_interest_account"/>
</changeSet>
@@ -84,6 +97,9 @@
</changeSet>
<changeSet author="fineract" id="5">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_savings_daily_interest"/>
</preConditions>
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
<column name="savings_product_id"/>
</createIndex>
@@ -110,4 +110,58 @@
</sql>
</changeSet>
<!--
Create the rows the update above assumes, and switch the feature on.
m_business_date is created empty by 0015_add_business_date.xml and nothing seeds it: the
rows only ever appear when somebody sets the date through the API. So on a brand-new tenant
changeSet 3 above updates nothing, the table stays empty, and enable-business-date stays
false — which means the whole platform silently falls back to the server clock. The business
date then cannot be advanced by the sync job this file registers, cannot be held back for a
branch still working yesterday, and every screen showing "business date" is showing the wall
clock instead.
Seeded at CURRENT_DATE, which is the only sensible starting point for a tenant being created
today, and guarded per row so an existing tenant that already has them is left alone.
-->
<changeSet author="fineract" id="5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_business_date WHERE type = 'BUSINESS_DATE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_business_date
(type, date, created_by, created_date, version, last_modified_by,
lastmodified_date, created_on_utc, last_modified_on_utc)
VALUES ('BUSINESS_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW());
</sql>
</changeSet>
<changeSet author="fineract" id="6">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_business_date WHERE type = 'COB_DATE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_business_date
(type, date, created_by, created_date, version, last_modified_by,
lastmodified_date, created_on_utc, last_modified_on_utc)
VALUES ('COB_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW());
</sql>
</changeSet>
<!--
Without this the rows above are ignored: DateUtils.getBusinessLocalDate() reads the tenant
clock unless this flag is on, so the sync job would move a date nothing consults.
-->
<changeSet author="fineract" id="7">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM c_configuration WHERE name = 'enable-business-date' AND enabled = false
</sqlCheck>
</preConditions>
<update tableName="c_configuration">
<column name="enabled" valueBoolean="true"/>
<where>name = 'enable-business-date'</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,203 @@
<?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.
-->
<!--
m_client_risk_rule holds two different kinds of rule that were never distinguishable:
KYC rules (0428) - profile-driven. Occupation / income / turnover decide a client's risk
category at onboarding. RBI Master Direction on KYC, Section 12.
AML rules (0430) - behaviour-driven. 221 transaction-monitoring scenarios ("Large cash
transaction", "Additional RFIs Rule - 91", ...) that must only fire off
posted transactions, surfacing in m_aml_alert. RBI MD Section 13.
The KYC risk engine was separating them by inference - "a rule with no occupation and no
income/turnover criteria must be an AML rule, so skip it". Migration 0448 broke that twice over:
- it set every NULL income/turnover column to 0.0, so no rule looks criteria-less any more;
- it applied a Medium-income band (50k-200k monthly, 6L-24L annual) to
"order_position BETWEEN 90 AND 240". That range was written for 0428's KYC occupation
rules, but order_position is not unique across the two seeds - it also caught 0430's
AML rules 91-94.
Net effect: a salaried client earning 1L monthly matched "Additional RFIs Rule - 91" and was
categorised Medium Risk with aml_check_status FLAGGED, ahead of the genuine income rule at
order_position 250. An unfired transaction-monitoring scenario was deciding a KYC category and
raising an AML flag on a customer who had transacted nothing - contrary to Sections 12 and 13.
Inference cannot be repaired in place. aml_check_status IS NOT NULL is not usable as the
discriminator either: KycRiskRulesWritePlatformServiceImpl defaults it to 'CLEAR' on create, so
every rule added through the Rules Master screen carries a non-null value regardless of kind.
This changeset therefore records the kind explicitly, so a rule declares what it is instead of
being guessed at, and any rule added later is classified by its author rather than by a pattern
in its criteria columns.
-->
<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.13.xsd">
<changeSet author="fineract" id="0538-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_risk_rule" columnName="rule_kind"/></not>
</preConditions>
<addColumn tableName="m_client_risk_rule">
<column name="rule_kind" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
<!--
Classify the existing catalog by how each row was written, not by what its criteria columns
say now - 0448 has already overwritten those, and not by rule_name either, because the AML
catalog runs to 219 rows across families that share no common wording ("Afghan Drug Business
- ...", "Credit Card Monitoring - ...", "Trade Based Money Laundering - ...", "FICN - ...").
The reliable signal is the disposition columns. 0430 sets aml_check_status on every row it
inserts - FLAGGED for a reportable scenario, BLOCKED for the terrorist-financing and FICN
ones. 0428's KYC insert never names the column at all, so its 47 rows hold NULL.
Only migration 0437 muddies this, having set aml_check_status = 'CLEAR' on ids 269-279 (rows
that sit past both seeds - added through the Rules Master screen). Testing for the two
values 0430 actually writes, rather than for "not null", leaves those rows out and lets them
fall through to KYC below, which is what they are.
-->
<changeSet author="fineract" id="0538-2">
<sql>
-- AML transaction-monitoring rules: anything carrying a live-transaction counterpart
-- in m_aml_transaction_rule is behaviour-driven by definition.
UPDATE m_client_risk_rule r
SET rule_kind = 'AML'
WHERE EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id);
-- The rest of 0430's catalog: seeded with a non-CLEAR disposition, and constraining no
-- customer attribute. Both halves must hold - a bank's own high-risk KYC rule could
-- legitimately be authored as FLAGGED, but it would name an occupation or an income
-- band, so the second test keeps it in the KYC family.
UPDATE m_client_risk_rule
SET rule_kind = 'AML'
WHERE rule_kind IS NULL
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
AND legal_form_id IS NULL
AND occupation_category_cv_id IS NULL
AND occupation_cv_id IS NULL
AND COALESCE(min_monthly_income, 0) = 0
AND COALESCE(max_monthly_income, 0) = 0
AND COALESCE(min_annual_income, 0) = 0
AND COALESCE(max_annual_income, 0) = 0
AND COALESCE(min_annual_turnover, 0) = 0
AND COALESCE(max_annual_turnover, 0) = 0;
-- The AML rows that 0448 additionally stamped with an income band, which is what put
-- them beyond the criteria test above.
--
-- 0448 applied a 50k-200k monthly / 6L-24L annual band to "order_position BETWEEN 90
-- AND 240", intending 0428's occupation rules. order_position is not unique across the
-- two seeds, so it landed on roughly 140 of 0430's scenarios as well - "Additional RFIs
-- Rule - 91" (the row that categorised a salaried employee as Medium Risk and flagged
-- them) along with the whole "Account Monitoring - ...", "Afghan Drug Business - ...",
-- "Credit Card Monitoring - ..." and "Customer Due Diligence - ..." families.
--
-- Those rows are identifiable precisely: they carry that exact band, still constrain no
-- occupation or legal form, and hold a disposition only 0430 writes. A genuine KYC rule
-- from 0428 in the same order_position range always names an occupation or occupation
-- category (NRI, Business Owner, Chartered Accountant, ...), so it cannot match here;
-- 0428's two bare income rules sit at 250 and 260, outside the band 0448 applied.
UPDATE m_client_risk_rule
SET rule_kind = 'AML'
WHERE rule_kind IS NULL
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
AND legal_form_id IS NULL
AND occupation_category_cv_id IS NULL
AND occupation_cv_id IS NULL
AND min_monthly_income = 50000
AND max_monthly_income = 200000
AND min_annual_income = 600000
AND max_annual_income = 2400000
AND COALESCE(min_annual_turnover, 0) = 0
AND COALESCE(max_annual_turnover, 0) = 0;
-- Likewise the 0430 rows caught by 0448's other two bands, for completeness: the
-- high-risk-occupation band at order_position 20/30/40/50 and the low-risk band at
-- 280-450. Same reasoning - a real 0428 rule in those ranges names an occupation.
UPDATE m_client_risk_rule
SET rule_kind = 'AML'
WHERE rule_kind IS NULL
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
AND legal_form_id IS NULL
AND occupation_category_cv_id IS NULL
AND occupation_cv_id IS NULL
AND (
(min_monthly_income = 200000 AND min_annual_income = 2400000)
OR (min_monthly_income = 0 AND max_monthly_income = 50000
AND min_annual_income = 0 AND max_annual_income = 600000)
);
-- Everything left is a KYC profile rule (0428, plus anything a bank has added since).
UPDATE m_client_risk_rule SET rule_kind = 'KYC' WHERE rule_kind IS NULL;
</sql>
</changeSet>
<!--
Undo 0448's misapplied income band on the AML rows. Leaving 0.0 in place would be harmless
now that rule_kind excludes them from matching, but a criteria-less scenario rule showing a
50k-200k income band on the AML Rules Master screen reads as a real threshold to whoever
reviews the catalog, which is misleading in an RBI inspection.
-->
<changeSet author="fineract" id="0538-3">
<sql>
UPDATE m_client_risk_rule
SET min_monthly_income = null,
max_monthly_income = null,
min_annual_income = null,
max_annual_income = null,
min_annual_turnover = null,
max_annual_turnover = null
WHERE rule_kind = 'AML';
</sql>
</changeSet>
<!--
0448 also flattened the genuine KYC rules' unset bounds to 0.0. The matching code treats a
0 bound as "not configured" (a `&gt; 0` guard accompanies every comparison), so this is
cosmetic rather than behavioural - but the same reasoning as above applies: a rule whose
real meaning is "no upper limit" should not display 0 as its ceiling.
-->
<changeSet author="fineract" id="0538-4">
<sql>
UPDATE m_client_risk_rule SET min_monthly_income = null WHERE rule_kind = 'KYC' AND min_monthly_income = 0;
UPDATE m_client_risk_rule SET max_monthly_income = null WHERE rule_kind = 'KYC' AND max_monthly_income = 0;
UPDATE m_client_risk_rule SET min_annual_income = null WHERE rule_kind = 'KYC' AND min_annual_income = 0;
UPDATE m_client_risk_rule SET max_annual_income = null WHERE rule_kind = 'KYC' AND max_annual_income = 0;
UPDATE m_client_risk_rule SET min_annual_turnover = null WHERE rule_kind = 'KYC' AND min_annual_turnover = 0;
UPDATE m_client_risk_rule SET max_annual_turnover = null WHERE rule_kind = 'KYC' AND max_annual_turnover = 0;
</sql>
</changeSet>
<!--
RBI MD Section 13 reserves an AML flag for behaviour actually observed. A profile rule must
never pre-flag a customer at onboarding, so the KYC catalog records CLEAR; a real alert is
raised against a posted transaction in m_aml_alert, not here.
-->
<changeSet author="fineract" id="0538-5">
<sql>
UPDATE m_client_risk_rule
SET aml_check_status = 'CLEAR'
WHERE rule_kind = 'KYC' AND aml_check_status = 'FLAGGED';
</sql>
</changeSet>
</databaseChangeLog>
@@ -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>
+73
View File
@@ -0,0 +1,73 @@
-- Closes KYC exceptions left open by verifications that actually passed.
--
-- The KYC tab wrote the exception columns *before* calling the vendor, when the check was by
-- definition still outstanding, and nothing rewrote them once the check passed. So a client could
-- end up with pan_verified / aadhaar_verified set while is_exception stayed true — the KYC tab
-- read the verified flags and showed "Verified", the exceptions register read is_exception and
-- kept the client in its Pending tab.
--
-- The frontend no longer leaves records in that state (it re-derives the exception columns on each
-- verification success), but rows written before that fix still carry it. This closes exactly
-- those, using the same rule the application derives: an exception whose every outstanding item
-- has since been settled is resolved.
--
-- Settled means verified by the vendor, or closed by paperwork — a signed PAN declaration or a
-- UIDAI enrolment receipt — which is what the application counts too. Aadhaar is not applicable to
-- non-individual clients (legal_form_enum = 2), which is why it is excluded rather than treated as
-- an outstanding item that can never pass.
--
-- exception_reason is deliberately left as-is: the Resolved tab shows what was resolved, so
-- clearing it would erase the record of why the exception was ever raised.
--
-- Safe to re-run — the WHERE clause matches nothing once the rows are closed.
--
-- Review first, then run:
-- SELECT ... (the SELECT below) to see what will change
-- then the UPDATE.
-- ── Review: the rows this will close ─────────────────────────────────────────
SELECT k.id AS kyc_id,
c.id AS client_id,
c.display_name,
k.exception_reason,
k.pan_verified,
k.aadhaar_verified,
k.pan_declaration_taken,
k.aadhaar_receipt_taken,
c.legal_form_enum
FROM m_client_kyc k
JOIN m_client c ON c.id = k.client_id
WHERE k.is_exception = TRUE
AND k.exception_resolved = FALSE
-- Only the two reasons the application manages automatically. A record also carrying an
-- officer-entered reason is left alone: only a human knows whether that item is settled.
AND k.exception_reason IS NOT NULL
AND TRIM(k.exception_reason) IN (
'PAN Card Verification Failed',
'Aadhaar Verification Failed',
'PAN Card Verification Failed, Aadhaar Verification Failed',
'Aadhaar Verification Failed, PAN Card Verification Failed'
)
-- PAN settled: verified, or closed by a signed declaration.
AND (k.pan_verified = TRUE OR COALESCE(k.pan_declaration_taken, FALSE) = TRUE)
-- Aadhaar settled: verified, closed by an enrolment receipt, or not applicable.
AND (k.aadhaar_verified = TRUE OR COALESCE(k.aadhaar_receipt_taken, FALSE) = TRUE OR c.legal_form_enum = 2);
-- ── Fix ──────────────────────────────────────────────────────────────────────
UPDATE m_client_kyc k
SET is_exception = FALSE,
exception_resolved = TRUE,
exception_resolved_on = CURRENT_DATE
FROM m_client c
WHERE c.id = k.client_id
AND k.is_exception = TRUE
AND k.exception_resolved = FALSE
AND k.exception_reason IS NOT NULL
AND TRIM(k.exception_reason) IN (
'PAN Card Verification Failed',
'Aadhaar Verification Failed',
'PAN Card Verification Failed, Aadhaar Verification Failed',
'Aadhaar Verification Failed, PAN Card Verification Failed'
)
AND (k.pan_verified = TRUE OR COALESCE(k.pan_declaration_taken, FALSE) = TRUE)
AND (k.aadhaar_verified = TRUE OR COALESCE(k.aadhaar_receipt_taken, FALSE) = TRUE OR c.legal_form_enum = 2);