diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 1c02f0dc2..4bbbdf8cc 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { 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 { 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 { * 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 changes, final java.util.function.Consumer 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 changes, final java.util.function.Consumer 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 changes, final java.util.function.Consumer 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 { 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 { 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 { 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 { } 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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java index d42bb01ab..b79dbe3d5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 32e53df8f..aaa9143aa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -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. + * + *

+ * 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java index adb90ec41..30dd40bb5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobSchedulerServiceImpl.java @@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 290d81092..b2926c7ff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index a9b60edf5..c9cce3fc3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -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 { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index 33bc6756b..4a7b680b4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -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"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java index 7eebbe34a..4150ec7cf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleData.java @@ -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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java index 5df50e4da..6f4b82865 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index 33e3347a3..957f003d6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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 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 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 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 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. + * + *

+ * 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. + * + *

+ * 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 changes) { return new CommandProcessingResultBuilder() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java new file mode 100644 index 000000000..ba568c32c --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRestrictionGuard.java @@ -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. + * + *

+ * 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. + * + *

The ladder

+ * + * + * + * + * + * + *
Days overdueMoney out (withdrawal, transfer out)Money in (deposit, transfer in)
0 – 89 (grace)allowedallowed
90 – 179stoppedallowed
180 and paststoppedstopped
+ * + *

+ * 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. + * + *

+ * 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. + * + *

Why the days are counted rather than read off the account

+ * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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> 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. + * + *

+ * 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> 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java index f896accad..4e14e91fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java index 9ee8786b0..67bdbf8f6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java @@ -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() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java index af0596d68..f3d98a4a1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestOrchestrator.java @@ -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 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. * diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java index c3b60665e..dbd2fd400 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsDailyInterestService.java @@ -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"); + } } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java index 561492159..724686933 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/SavingsPooledInterestPostingService.java @@ -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: * *

- *   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
  * 
* - * 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&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: + * + *
+ *   Dr  Savings Interest Payable   (per member)
+ *   Cr  Savings Control            (per member)
+ * 
+ * + * 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 + * + *

+ * 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 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. + * + *

+ * 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. + * + *

+ * 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 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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java index 5b8a3977e..36cc6cf73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/dailyinterest/api/SavingsDailyInterestApiResource.java @@ -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 result = new LinkedHashMap<>(); - result.put("ranFor", businessDate.toString()); + result.put("ranFor", runFor.toString()); return gson.toJson(result); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 62bacbc6c..90f2457f9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java new file mode 100644 index 000000000..e576911c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormancyClock.java @@ -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. + * + *

+ * 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. + * + *

What counts

+ * + *

+ * 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. + * + *

+ * 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). + * + *

+ * 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)) + """; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java index 6d5322c63..895dd1cea 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DormantAccountReadPlatformServiceImpl.java @@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead public List 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 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. + * + *

+ * 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index 3fa41d175..008486db0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -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. + * + *

+ * 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. + * + *

+ * A row dated after 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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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 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 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 "); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 2cfc99881..1eef688a9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 108e7421a..020cdbd2f 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -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} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index d0c837041..e75b002f7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -611,4 +611,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml index 44a3207b6..13012f10f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_seed_forfeit_unclaimed_dividends_job.xml @@ -52,4 +52,31 @@ + + + + + SELECT COUNT(*) FROM information_schema.columns + WHERE table_name = 'job' AND column_name = 'id' AND is_identity = 'YES' + + + + SELECT setval(pg_get_serial_sequence('job', 'id'), (SELECT MAX(id) FROM job)) + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml index ab7290a59..5fbd92956 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0517_create_savings_daily_interest_ledger.xml @@ -236,4 +236,61 @@ referencedTableName="m_savings_interest_posting_run" referencedColumnNames="id"/> + + + + + + + + + + + DELETE FROM m_savings_daily_interest d + WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x + WHERE x.savings_account_id = d.savings_account_id) + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml index d73074fd4..684d73bc8 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0525_savings_daily_interest_job.xml @@ -59,6 +59,19 @@ unposted accruals are picked up on the first run rather than the second. --> + + + + INSERT INTO m_savings_interest_posting_schedule (savings_product_id, posting_period_enum, run_at_time, is_active) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml index b09709538..9272e724f 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0526_savings_interest_summary_view.xml @@ -38,6 +38,19 @@ start, so the day after a posting it begins again at that day's interest. --> + + + + SELECT d.savings_account_id AS savings_account_id, diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml index a093b16d1..a28aee782 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0527_savings_daily_interest_one_row_per_account.xml @@ -60,6 +60,16 @@ created. The latest day is kept: it is the one carrying the current cycle's running total. --> + + + + DELETE FROM m_savings_daily_interest d WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x @@ -68,6 +78,9 @@ + + + @@ -84,6 +97,9 @@ + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml index b28b4c8b4..03884e16d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0535_sync_business_date_to_system_date_job.xml @@ -110,4 +110,58 @@ + + + + SELECT COUNT(*) FROM m_business_date WHERE type = 'BUSINESS_DATE' + + + 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()); + + + + + + SELECT COUNT(*) FROM m_business_date WHERE type = 'COB_DATE' + + + 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()); + + + + + + + + SELECT COUNT(*) FROM c_configuration WHERE name = 'enable-business-date' AND enabled = false + + + + + name = 'enable-business-date' + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml new file mode 100644 index 000000000..399f961ea --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0538_separate_kyc_and_aml_risk_rules.xml @@ -0,0 +1,203 @@ + + + + + + + + + + + + + + + + + + -- 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; + + + + + + + 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'; + + + + + + + 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; + + + + + + + UPDATE m_client_risk_rule + SET aml_check_status = 'CLEAR' + WHERE rule_kind = 'KYC' AND aml_check_status = 'FLAGGED'; + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 1a0057291..c441155be 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -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 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 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 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 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 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. + * + *

+ * 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. + * + *

+ * 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. + * + *

+ * 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 return false; } + /** + * Revives a dormant account, on the strength of a completed re-KYC. + * + *

+ * 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. + * + *

+ * 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)) { diff --git a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml index 78ca69ec5..5c4f2ddf1 100644 --- a/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml +++ b/fineract-savings/src/main/resources/db/changelog/tenant/module/savings/parts/parts/2011_add_facility_charge_mapping.xml @@ -293,4 +293,52 @@ + + + + + + + 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'); + + + diff --git a/sql/fix_stale_kyc_exceptions.sql b/sql/fix_stale_kyc_exceptions.sql new file mode 100644 index 000000000..c9a7f08e8 --- /dev/null +++ b/sql/fix_stale_kyc_exceptions.sql @@ -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);