feat: separate kyc/aml risk rules, dormancy clock, and savings daily interest fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
This commit is contained in:
+75
-16
@@ -355,7 +355,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
this.dinVerified = false;
|
||||
this.isException = false;
|
||||
this.exceptionResolved = false;
|
||||
this.initiatedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.initiatedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
|
||||
@@ -562,10 +562,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
v -> this.form15Applicable = v);
|
||||
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
|
||||
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
|
||||
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
|
||||
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
|
||||
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
|
||||
// The four exception columns are derived from whether the identity checks have passed, not
|
||||
// data an officer types, so they are updated without recording an overwrite. A verified
|
||||
// record demands remarks for any field it already held (see
|
||||
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
|
||||
// counting these would make closing a settled exception impossible without a justification
|
||||
// for something nobody chose. That is what left clients verified on the KYC tab while the
|
||||
// exceptions register still listed them as Pending.
|
||||
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
|
||||
v -> this.isException = v != null ? v : false);
|
||||
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
|
||||
v -> this.exceptionReason = v);
|
||||
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
|
||||
v -> this.exceptionResolved = v != null ? v : false);
|
||||
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
|
||||
v -> this.exceptionResolvedOn = v);
|
||||
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
|
||||
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
|
||||
@@ -620,6 +631,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
* populated is the record being completed rather than altered, so it is deliberately not
|
||||
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
|
||||
*/
|
||||
/*
|
||||
* Derived-field variants of the update helpers above.
|
||||
*
|
||||
* Identical, minus the recordOverwrite call: these columns are computed from whether the
|
||||
* identity checks have passed rather than entered by an officer, so replacing one is not the
|
||||
* kind of change a verified record demands a justification for. They still report into
|
||||
* `changes`, so the update is persisted and audited like any other.
|
||||
*/
|
||||
|
||||
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
|
||||
if (command.parameterExists(param)) {
|
||||
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
|
||||
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
|
||||
changes.put(param, newValue);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
|
||||
if (command.isChangeInStringParameterNamed(param, current)) {
|
||||
final String newValue = command.stringValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
|
||||
}
|
||||
}
|
||||
|
||||
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
|
||||
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
|
||||
if (command.isChangeInLocalDateParameterNamed(param, current)) {
|
||||
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
|
||||
changes.put(param, newValue);
|
||||
setter.accept(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
private void recordOverwrite(final String param, final Object currentValue) {
|
||||
final boolean wasPopulated = currentValue instanceof String text
|
||||
? StringUtils.isNotBlank(text)
|
||||
@@ -670,13 +719,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
public void markCkycUploaded(final String ckycNumber, final String batchId) {
|
||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
|
||||
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
/** Called after successful 3rd-party verification of all required documents. */
|
||||
public void markVerified(final String ckycNumber) {
|
||||
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
|
||||
this.verifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.verifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
|
||||
|
||||
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
|
||||
@@ -709,21 +758,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
return this.expiryDate;
|
||||
}
|
||||
|
||||
this.expiryDate = DateUtils.getLocalDateOfTenant().plusYears(risk.getReKycYears());
|
||||
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
|
||||
return this.expiryDate;
|
||||
}
|
||||
|
||||
/** Called when verification fails. */
|
||||
public void markRejected(final String remarks) {
|
||||
this.kycStatus = ClientKycStatus.REJECTED.getValue();
|
||||
this.rejectedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.rejectedOn = DateUtils.getBusinessLocalDate();
|
||||
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
|
||||
}
|
||||
|
||||
public void markPanVerified(final String fullName, final String firstName, final String lastName,
|
||||
final String aadhaarSeedingStatus, final String category, final String status) {
|
||||
this.panVerified = true;
|
||||
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
|
||||
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
|
||||
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
|
||||
@@ -749,28 +798,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
public void markAadhaarVerified() {
|
||||
this.aadhaarVerified = true;
|
||||
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markGstinVerified() {
|
||||
this.gstinVerified = true;
|
||||
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markCinVerified() {
|
||||
this.cinVerified = true;
|
||||
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markDinVerified() {
|
||||
this.dinVerified = true;
|
||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
|
||||
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
|
||||
this.dinNumber = dinNumber;
|
||||
this.dinVerified = true;
|
||||
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
|
||||
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
|
||||
this.isDirector = true;
|
||||
this.dinMcaResponse = mcaResponseJson;
|
||||
}
|
||||
@@ -791,7 +840,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
} else if (type != null && type.isNonIndividual()) {
|
||||
allDone = this.panVerified && this.gstinVerified;
|
||||
}
|
||||
if (allDone && !getKycStatus().isVerified()) {
|
||||
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
|
||||
// periodic re-KYC is: an already-verified customer verified afresh.
|
||||
//
|
||||
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
|
||||
// that now measures from it — frozen at the original onboarding. An officer who re-verified
|
||||
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
|
||||
// account still sitting on the inactive report, with nothing to say what was missing.
|
||||
//
|
||||
// Guarded on the verifications rather than the status, so this only fires when the checks
|
||||
// that constitute a verification have actually been done again.
|
||||
if (allDone) {
|
||||
markVerified(this.ckycNumber);
|
||||
}
|
||||
return allDone;
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
|
||||
+47
-10
@@ -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);
|
||||
|
||||
// Stage 2: the liability moves to the account it is paid out of.
|
||||
// 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 3: into the member's own balance.
|
||||
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
// Stage 2: into the member's own balance.
|
||||
final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount;
|
||||
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
|
||||
isReversal);
|
||||
}
|
||||
|
||||
@@ -1380,6 +1394,29 @@ public class AccountingProcessorHelper {
|
||||
return accountMapping.getGlAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the savings product's Accounting tab, or null where the bank has not filled it in.
|
||||
*
|
||||
* <p>
|
||||
* Separate from {@link #getLinkedGLAccountForSavingsProduct}, which throws: not every row is
|
||||
* required. Interest Payable is offered on the product screen but a product configured before it
|
||||
* existed has none, and that has to fall through to the next choice rather than stopping the
|
||||
* account from being closed.
|
||||
*/
|
||||
private GLAccount optionalLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||
final Long paymentTypeId) {
|
||||
if (savingsProductId == null) {
|
||||
return null;
|
||||
}
|
||||
// Read directly rather than by catching what getLinkedGLAccountForSavingsProduct throws: on a
|
||||
// missing mapping that method builds its message through CashAccountsForSavings.fromInt,
|
||||
// which returns null for a row the cash enum does not carry - Interest Payable among them -
|
||||
// and the lookup would die on a NullPointerException instead of the exception meant for it.
|
||||
final ProductToGLAccountMapping mapping = this.accountMappingRepository.findCoreProductToFinAccountMapping(savingsProductId,
|
||||
PortfolioProductType.SAVING.getValue(), accountMappingTypeId);
|
||||
return mapping == null ? null : mapping.getGlAccount();
|
||||
}
|
||||
|
||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||
final Long paymentTypeId) {
|
||||
GLAccount glAccount;
|
||||
|
||||
+17
@@ -66,6 +66,23 @@ public class JobSchedulerServiceImpl implements ApplicationListener<ContextRefre
|
||||
final List<ScheduledJobDetail> scheduledJobDetails = schedularWritePlatformService
|
||||
.retrieveAllJobs(fineractProperties.getNodeId());
|
||||
for (final ScheduledJobDetail jobDetails : scheduledJobDetails) {
|
||||
// Release any currently_running flag left over from before this boot.
|
||||
//
|
||||
// The flag is set in the scheduler's veto check before a job's body runs and cleared
|
||||
// by the Quartz listener when it returns. Two things leave it set with nothing
|
||||
// running: a JVM that stopped mid-run, and a job started through its own API rather
|
||||
// than through Quartz — no listener is involved there, so nothing ever clears it.
|
||||
//
|
||||
// Once set, the veto silently drops every later trigger: no run-history row, no
|
||||
// error, nothing in the log. The job reads as though it ran and did nothing, and one
|
||||
// dead run mutes it for good. That is how a night of unpaid savings interest goes
|
||||
// unnoticed.
|
||||
//
|
||||
// This loop is the platform coming up, so by definition none of these jobs is
|
||||
// running: whatever the flag says, it is stale. scheduleJob() below already clears
|
||||
// it on the paths it reaches, but not when it throws before that line, and the
|
||||
// saveOrUpdate() after this loop persists whatever we set here either way.
|
||||
jobDetails.setCurrentlyRunning(false);
|
||||
try {
|
||||
jobRegisterService.scheduleJob(jobDetails);
|
||||
} catch (JobIsNotFoundOrNotEnabledException e) {
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
|
||||
+10
@@ -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 {
|
||||
|
||||
+9
@@ -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");
|
||||
|
||||
+10
-1
@@ -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;
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
+82
-5
@@ -52,6 +52,8 @@ import org.apache.fineract.portfolio.client.exception.ClientKycInvalidStateTrans
|
||||
import org.apache.fineract.portfolio.client.kyc.KycVerificationResult;
|
||||
import org.apache.fineract.portfolio.client.kyc.KycVerificationService;
|
||||
import org.apache.fineract.portfolio.client.serialization.ClientKycCommandFromApiJsonDeserializer;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.dao.DataIntegrityViolationException;
|
||||
@@ -73,6 +75,7 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
private final KycVerificationService kycVerificationService;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final org.apache.fineract.portfolio.client.ckyc.CkycSftpUploadService ckycSftpUploadService;
|
||||
private final SavingsAccountRepository savingsAccountRepository;
|
||||
private final org.apache.fineract.infrastructure.documentmanagement.service.DocumentWritePlatformService documentWritePlatformService;
|
||||
|
||||
/** Hard cap on uploaded CKYC ZIP size to keep the byte[] copy bounded (50 MB). */
|
||||
@@ -414,8 +417,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
getJsonString(panData, "aadhaar_seeding_status"),
|
||||
getJsonString(panData, "category"),
|
||||
getJsonString(panData, "status"));
|
||||
kyc.tryAutoVerify();
|
||||
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||
// same one the explicit command clears.
|
||||
if (fullyVerified) {
|
||||
reviveDormantAccounts(clientId);
|
||||
}
|
||||
LOG.info("verifyPan: SUCCESS for clientId={}, kycId={}, kycStatus={}", clientId, kycId, kyc.getKycStatusValue());
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
@@ -458,8 +466,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
}
|
||||
|
||||
kyc.markAadhaarVerified();
|
||||
kyc.tryAutoVerify();
|
||||
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||
// same one the explicit command clears.
|
||||
if (fullyVerified) {
|
||||
reviveDormantAccounts(clientId);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
changes.put("aadhaarVerified", true);
|
||||
@@ -500,8 +513,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
}
|
||||
|
||||
kyc.markGstinVerified();
|
||||
kyc.tryAutoVerify();
|
||||
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||
// same one the explicit command clears.
|
||||
if (fullyVerified) {
|
||||
reviveDormantAccounts(clientId);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
changes.put("gstinVerified", true);
|
||||
@@ -563,8 +581,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
}
|
||||
|
||||
kyc.markCinVerified();
|
||||
kyc.tryAutoVerify();
|
||||
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||
// same one the explicit command clears.
|
||||
if (fullyVerified) {
|
||||
reviveDormantAccounts(clientId);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
changes.put("cinVerified", true);
|
||||
@@ -633,8 +656,13 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
final boolean companyNameMatched = matchedCompany != null;
|
||||
|
||||
kyc.markDinVerified(dinNumber, rawMcaResponse);
|
||||
kyc.tryAutoVerify();
|
||||
final boolean fullyVerified = kyc.tryAutoVerify();
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
// Both checks now done, so this is a completed re-KYC and the dormancy it clears is the
|
||||
// same one the explicit command clears.
|
||||
if (fullyVerified) {
|
||||
reviveDormantAccounts(clientId);
|
||||
}
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
changes.put("dinNumber", dinNumber);
|
||||
@@ -817,11 +845,27 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
kyc.markVerified(kyc.getCkycNumber());
|
||||
kycRepositoryWrapper.saveAndFlush(kyc);
|
||||
|
||||
// A completed re-KYC is what revives the customer's dormant accounts.
|
||||
//
|
||||
// RBI's Master Direction allows a dormant account to be operated again only once the
|
||||
// customer's identity has been established afresh, and this command is that establishment.
|
||||
// Withdrawals are refused while an account is dormant and no transaction lifts it, so
|
||||
// without this the account would stay frozen with no way out.
|
||||
//
|
||||
// Done here rather than in the nightly expiry sweep on purpose: that sweep visits every
|
||||
// customer whose KYC is merely current, so reviving there would reopen a dormant account on
|
||||
// the next run without anyone having verified anything.
|
||||
final int revived = reviveDormantAccounts(clientId);
|
||||
|
||||
final Map<String, Object> changes = new java.util.LinkedHashMap<>();
|
||||
changes.put("verifiedOn", kyc.getVerifiedOn());
|
||||
changes.put("expiryDate", kyc.getExpiryDate());
|
||||
changes.put("kycStatus", kyc.getKycStatusValue());
|
||||
changes.put("kycStatusCode", "kyc.status.verified");
|
||||
if (revived > 0) {
|
||||
changes.put("dormantAccountsRevived", revived);
|
||||
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
|
||||
}
|
||||
return buildResult(command, client, clientId, kycId, changes);
|
||||
}
|
||||
|
||||
@@ -1040,6 +1084,39 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc
|
||||
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Clears the dormancy flag from this client's savings accounts, on the strength of a completed
|
||||
* re-KYC.
|
||||
*
|
||||
* <p>
|
||||
* RBI's Master Direction allows a dormant account to be operated again only once the customer's
|
||||
* identity has been established afresh, so this is the one thing that lifts it — no transaction
|
||||
* does, and a withdrawal is refused while the flag stands. Called from every path that
|
||||
* completes a verification, not only the explicit complete-rekyc command: an officer who
|
||||
* re-verifies PAN and Aadhaar at the counter has done the same verification and must not have
|
||||
* to know about a second button.
|
||||
*
|
||||
* <p>
|
||||
* Deliberately not called from the nightly expiry sweep, which visits every customer whose KYC
|
||||
* is merely current — reviving there would reopen a dormant account on the next run without
|
||||
* anyone having verified anything.
|
||||
*
|
||||
* @return how many accounts were revived
|
||||
*/
|
||||
private int reviveDormantAccounts(final Long clientId) {
|
||||
int revived = 0;
|
||||
for (final SavingsAccount account : savingsAccountRepository.findSavingAccountByClientId(clientId)) {
|
||||
if (account.reviveOnReKyc()) {
|
||||
savingsAccountRepository.save(account);
|
||||
revived++;
|
||||
}
|
||||
}
|
||||
if (revived > 0) {
|
||||
LOG.info("Client {} completed re-KYC — {} dormant savings account(s) revived", clientId, revived);
|
||||
}
|
||||
return revived;
|
||||
}
|
||||
|
||||
private CommandProcessingResult buildResult(final JsonCommand command, final Client client,
|
||||
final Long clientId, final Long kycId, final Map<String, Object> changes) {
|
||||
return new CommandProcessingResultBuilder()
|
||||
|
||||
+201
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.client.service;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.portfolio.client.domain.KycExpiryStage;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* What a customer with a lapsed periodic re-KYC may still do with their savings account.
|
||||
*
|
||||
* <p>
|
||||
* One service because the answer has to be the same at every counter the money can leave by — the
|
||||
* clerk's withdrawal request, the cashier's deposit, an account-to-account transfer, a transfer to
|
||||
* or from a GL. Those are separate resources that each validate their own input, and a rule copied
|
||||
* into four of them is a rule that will disagree with itself the first time one is edited.
|
||||
*
|
||||
* <h3>The ladder</h3>
|
||||
*
|
||||
* <table>
|
||||
* <tr><th>Days overdue</th><th>Money out (withdrawal, transfer out)</th><th>Money in (deposit, transfer in)</th></tr>
|
||||
* <tr><td>0 – 89 (grace)</td><td>allowed</td><td>allowed</td></tr>
|
||||
* <tr><td>90 – 179</td><td>stopped</td><td>allowed</td></tr>
|
||||
* <tr><td>180 and past</td><td>stopped</td><td>stopped</td></tr>
|
||||
* </table>
|
||||
*
|
||||
* <p>
|
||||
* Nothing at all is restricted during the grace window, and that is the point of it: RBI's 2021
|
||||
* amendment is explicit that an account is not to be restricted merely because periodic KYC is
|
||||
* pending, and that any restriction follows notice and a fair opportunity to comply. The customer
|
||||
* is being chased, not shut out.
|
||||
*
|
||||
* <p>
|
||||
* The line is drawn by direction, not by the counter the money crosses. Anything taking money out
|
||||
* is stopped once debits stop — a transfer out is a withdrawal by another name — while anything
|
||||
* paying money in keeps working, whether it is cash at the counter or a transfer from another
|
||||
* account. A salary, a benefit, a relative sending funds: those must land, and none of them can
|
||||
* move the balance anywhere while the debit side is shut.
|
||||
*
|
||||
* <h3>Why the days are counted rather than read off the account</h3>
|
||||
*
|
||||
* <p>
|
||||
* The nightly job stamps the restriction on the savings account. Between a customer crossing a
|
||||
* threshold and that job next running the account still reads as unrestricted, and a request
|
||||
* raised in that window is accepted — the clerk is told it went through and the customer waits for
|
||||
* money that was never going to be paid. Counting here closes that window, and matches the job so
|
||||
* the two can never disagree.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KycRestrictionGuard {
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** What the caller is trying to do, which decides how far down the ladder it survives. */
|
||||
public enum Operation {
|
||||
|
||||
/** Money out over the counter. Stopped once debits stop. */
|
||||
DEBIT("Withdrawal"),
|
||||
/** Money in, however it arrives. Allowed until the account is inoperative. */
|
||||
CREDIT("Deposit"),
|
||||
/** Money out to another account or a GL — a withdrawal by another name, so stopped with them. */
|
||||
TRANSFER("Transfer");
|
||||
|
||||
private final String label;
|
||||
|
||||
Operation(final String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getLabel() {
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses the operation where the customer's re-KYC has lapsed far enough to stop it.
|
||||
*
|
||||
* <p>
|
||||
* Silent for a null account, a client with no KYC record, and one whose KYC has no expiry date
|
||||
* — none of those is a lapsed re-KYC, and refusing on absent data would block a counter over a
|
||||
* record that was never set up.
|
||||
*
|
||||
* @throws PlatformDataIntegrityException naming the stage and what would lift it
|
||||
*/
|
||||
public void check(final Long savingsAccountId, final Operation operation) {
|
||||
checkDormancy(savingsAccountId, operation);
|
||||
checkKycExpiry(savingsAccountId, operation);
|
||||
}
|
||||
|
||||
private void checkKycExpiry(final Long savingsAccountId, final Operation operation) {
|
||||
final Long daysOverdue = daysOverdue(savingsAccountId);
|
||||
if (daysOverdue == null) {
|
||||
return;
|
||||
}
|
||||
final KycExpiryStage stage = KycExpiryStage.fromDaysOverdue(daysOverdue);
|
||||
if (stage == null || stage.isGrace()) {
|
||||
// Inside the grace window nothing is restricted; the customer is only being chased.
|
||||
return;
|
||||
}
|
||||
// Past grace, a credit is the one thing still let through, until the account is inoperative.
|
||||
if (operation == Operation.CREDIT && !stage.isInoperative()) {
|
||||
return;
|
||||
}
|
||||
throw new PlatformDataIntegrityException("error.msg.client.kyc.lapsed." + operation.name().toLowerCase(),
|
||||
operation.getLabel() + " is not allowed. The customer's periodic re-KYC lapsed " + daysOverdue
|
||||
+ " days ago. Complete the re-KYC to lift this.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses money leaving, or moving, on an account nobody has operated for the product's
|
||||
* dormancy period.
|
||||
*
|
||||
* <p>
|
||||
* Checked alongside the re-KYC ladder because the two restrict for the same reason and are
|
||||
* lifted by the same act: an account untouched for two years is the one an impersonator
|
||||
* reaches for, and RBI allows it to be operated again only once the customer's identity has
|
||||
* been established afresh. Deposits are left alone — money coming in cannot be a fraud against
|
||||
* the customer — but a transfer moves the balance somewhere a debit block does not follow, so
|
||||
* it is refused in either direction.
|
||||
*
|
||||
* <p>
|
||||
* The days are counted from the same clock the ladder measures, so an account that crossed the
|
||||
* threshold before the nightly job stamped it is caught all the same.
|
||||
*/
|
||||
private void checkDormancy(final Long savingsAccountId, final Operation operation) {
|
||||
if (savingsAccountId == null || operation == Operation.CREDIT) {
|
||||
return;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sp.days_to_dormancy,
|
||||
(SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE')
|
||||
- GREATEST(sa.activatedon_date,
|
||||
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
|
||||
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
|
||||
sa.activatedon_date)) AS days_idle
|
||||
FROM m_savings_account sa
|
||||
JOIN m_savings_product sp ON sp.id = sa.product_id
|
||||
WHERE sa.id = ? AND sp.is_dormancy_tracking_active = true AND sp.days_to_dormancy > 0
|
||||
""", savingsAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final Number threshold = (Number) rows.get(0).get("days_to_dormancy");
|
||||
final Number daysIdle = (Number) rows.get(0).get("days_idle");
|
||||
if (threshold == null || daysIdle == null || daysIdle.longValue() < threshold.longValue()) {
|
||||
return;
|
||||
}
|
||||
final long years = threshold.longValue() / 365;
|
||||
final String period = years >= 1 ? years + (years == 1 ? " year" : " years") : threshold + " days";
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.account.dormant." + operation.name().toLowerCase(),
|
||||
operation.getLabel() + " is not allowed. The account is dormant — no customer-initiated transaction for "
|
||||
+ period + ". It must be reactivated after the customer's identity is re-verified (re-KYC).");
|
||||
}
|
||||
|
||||
/**
|
||||
* Days past the customer's re-KYC due date, or null where there is nothing overdue to measure.
|
||||
*
|
||||
* <p>
|
||||
* Measured against the business date, not the wall clock, so a branch working a backdated day
|
||||
* sees the restrictions that applied on that day.
|
||||
*/
|
||||
public Long daysOverdue(final Long savingsAccountId) {
|
||||
if (savingsAccountId == null) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT (SELECT date FROM m_business_date WHERE type = 'BUSINESS_DATE') - k.expiry_date AS days_overdue
|
||||
FROM m_savings_account sa
|
||||
JOIN m_client_kyc k ON k.client_id = sa.client_id
|
||||
WHERE sa.id = ? AND k.expiry_date IS NOT NULL
|
||||
ORDER BY k.expiry_date DESC
|
||||
LIMIT 1
|
||||
""", savingsAccountId);
|
||||
if (rows.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
final Number daysOverdue = (Number) rows.get(0).get("days_overdue");
|
||||
return daysOverdue == null || daysOverdue.longValue() < 0 ? null : daysOverdue.longValue();
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
|
||||
+45
-1
@@ -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()
|
||||
|
||||
+58
-10
@@ -48,8 +48,19 @@ public class SavingsDailyInterestOrchestrator {
|
||||
private final SavingsDailyInterestService accrualService;
|
||||
private final SavingsPooledInterestPostingService postingService;
|
||||
|
||||
/** Accrue for the day, then post for any product whose cycle ends today. */
|
||||
/**
|
||||
* Accrue for the day, then post for any product whose posting date has arrived.
|
||||
*
|
||||
* Deliberately not {@code @Transactional}. The accrual writes every account's ledger row and the
|
||||
* posting then reads and updates those same rows in a transaction of its own
|
||||
* ({@code postForProduct} is REQUIRES_NEW, so that one product's failure cannot roll back
|
||||
* another's payment). Holding an outer transaction across both would mean this thread still owns
|
||||
* the row locks the accrual took while the posting's separate transaction waits for them — each
|
||||
* waiting on the other, and the job hanging silently with nothing in the log. So each step
|
||||
* commits on its own before the next begins.
|
||||
*/
|
||||
public void runForDate(final LocalDate businessDate) {
|
||||
clearStaleRunningFlag();
|
||||
accrualService.accrueForDate(businessDate);
|
||||
enrolNewProducts();
|
||||
|
||||
@@ -73,18 +84,27 @@ public class SavingsDailyInterestOrchestrator {
|
||||
|
||||
for (final Map<String, Object> schedule : schedules) {
|
||||
final long productId = ((Number) schedule.get("savings_product_id")).longValue();
|
||||
final Long dueCount = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_savings_daily_interest
|
||||
WHERE savings_product_id = ? AND balance_date = ? AND is_posting_day = true
|
||||
AND savings_transaction_id IS NULL AND cycle_cumulative_interest > 0
|
||||
""", Long.class, productId, businessDate);
|
||||
if (dueCount == null || dueCount == 0) {
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
try {
|
||||
final BigDecimal paid = postingService.postForProduct(productId, businessDate);
|
||||
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.
|
||||
*
|
||||
|
||||
+22
-1
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+142
-61
@@ -28,7 +28,6 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Propagation;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
@@ -40,28 +39,41 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
* a run that dies at the halfway mark leaves some members paid and some not, and nothing anywhere
|
||||
* holds the figure that would say so.
|
||||
*
|
||||
* This posts the product's total first, into the liability the product names as its Savings Interest
|
||||
* Payable, moves it to the account it is paid out of, and only then pays it to members:
|
||||
* This settles the debt the accrual has already recognised: the product's total moves out of the
|
||||
* liability the product names as its Savings Interest Payable, into the account it is paid out of,
|
||||
* and only then to the members:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Interest On Savings (expense, from the product)
|
||||
* Cr Savings Interest Payable (liability, from the product) what the bank now owes
|
||||
*
|
||||
* Dr Savings Interest Payable
|
||||
* Dr Savings Interest Payable (liability, from the product)
|
||||
* Cr Savings Interest Paid on its way out
|
||||
*
|
||||
* Dr Savings Interest Paid (per member)
|
||||
* Cr Savings Control (per member, from the product) in the member's hands
|
||||
* </pre>
|
||||
*
|
||||
* Every account named above is read from the product's own GL configuration; none is written into
|
||||
* this class. A product that names no paid account collapses the middle step - both legs resolve to
|
||||
* the payable - and posts the two-legged entry it always did; one that names no payable falls back
|
||||
* to its own Savings Control, since interest owed is a liability either way.
|
||||
* The interest expense is not raised here. It is recognised as the interest accrues, day by day, and
|
||||
* this is the settlement of what that recognition already booked - raising the expense again at
|
||||
* payout would put the same interest on the P&L twice.
|
||||
*
|
||||
* The whole product settles inside one transaction, and the payable has to read zero before it is
|
||||
* allowed to commit. A failure rolls the product back entirely - nobody is half paid - and a run
|
||||
* that never reached commit leaves a PENDING row naming the product and date for the next run.
|
||||
* Every account named above is read from the product's own GL configuration; none is written into
|
||||
* this class. The payable comes from the Interest Payable row on the product's Accounting tab, and
|
||||
* where that is unset, from the product's reserved GL mapping of the same name, and failing both
|
||||
* from its Savings Control - interest owed is a liability either way.
|
||||
*
|
||||
* The first step is dropped where it would say nothing, and the payout then comes straight out of
|
||||
* the payable:
|
||||
*
|
||||
* <pre>
|
||||
* Dr Savings Interest Payable (per member)
|
||||
* Cr Savings Control (per member)
|
||||
* </pre>
|
||||
*
|
||||
* That happens when the product names no paid account, so both legs of the first step resolve to the
|
||||
* payable and it would debit and credit one account for the same amount.
|
||||
*
|
||||
* The whole product settles inside one transaction. A failure rolls the product back entirely -
|
||||
* nobody is half paid - and a run that never reached commit leaves a PENDING row naming the product
|
||||
* and date for the next run.
|
||||
*
|
||||
* The paise problem is the one worth stating plainly. Each member's interest is rounded to two
|
||||
* decimals, and rounded amounts do not generally add up to the rounded total. Left alone the pool
|
||||
@@ -92,20 +104,32 @@ public class SavingsPooledInterestPostingService {
|
||||
private static final int INTEREST_POSTING = 3;
|
||||
/** acc_gl_journal_entry.entity_type_enum for a savings entry. */
|
||||
private static final int ENTITY_TYPE_SAVINGS = 2;
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS = 3;
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_CONTROL = 2;
|
||||
/**
|
||||
* {@code AccrualAccountsForSavings.INTEREST_PAYABLE} — the Interest Payable row on the product's
|
||||
* Accounting tab, which is where a bank picks the account interest is owed from.
|
||||
*/
|
||||
private static final int GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE = 17;
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/**
|
||||
* Posts one product's interest for one posting date.
|
||||
*
|
||||
* REQUIRES_NEW so that a product that fails is rolled back on its own without taking the other
|
||||
* product's completed run down with it.
|
||||
*
|
||||
* @return the amount distributed, or zero if there was nothing to do
|
||||
*
|
||||
* <p>
|
||||
* REQUIRED, not REQUIRES_NEW.
|
||||
*
|
||||
* One product's failure must not roll back another's payment, and REQUIRED gives that already:
|
||||
* the orchestrator calls this outside any transaction of its own, so each product opens and
|
||||
* commits its own. REQUIRES_NEW would too — but it also suspends whatever transaction is
|
||||
* running and waits for locks that transaction may hold. The accrual step immediately before
|
||||
* writes every account's ledger row, and this method reads and updates those same rows: with
|
||||
* REQUIRES_NEW the new transaction sat waiting for locks the caller's own thread was holding,
|
||||
* and the job hung with nothing in the log and its currently_running flag left set.
|
||||
*/
|
||||
@Transactional(propagation = Propagation.REQUIRES_NEW)
|
||||
@Transactional
|
||||
public BigDecimal postForProduct(final long productId, final LocalDate postingDate) {
|
||||
final Long completed = jdbcTemplate.queryForObject("""
|
||||
SELECT COUNT(*) FROM m_savings_interest_posting_run
|
||||
@@ -116,8 +140,18 @@ public class SavingsPooledInterestPostingService {
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
// The accounts due today, with what they accrued. Anything already carrying a posting
|
||||
// transaction is skipped, so a resumed run cannot pay somebody a second time.
|
||||
// Every account whose posting date has arrived or passed and has not yet been paid.
|
||||
//
|
||||
// Read by posting_date rather than by the run date: the ledger keeps one row per account and
|
||||
// overwrites it daily, so on the day after a quarter end the row saying "this was a posting
|
||||
// day" no longer exists. Matching on the run date alone meant a quarter end the job did not
|
||||
// run on was never paid at all — the cycle simply rolled over with the interest carried into
|
||||
// it. A posting that is owed stays owed until it is made, which is how an overnight batch has
|
||||
// to behave when a night is missed.
|
||||
//
|
||||
// Anything already carrying a posting transaction is skipped, so a resumed run cannot pay
|
||||
// somebody a second time.
|
||||
//
|
||||
// A savings account has no office of its own: it belongs to a client, or to a group when
|
||||
// there is no client, and the office is theirs. The journal entries have to be raised
|
||||
// against that office or they land in the wrong branch's books.
|
||||
@@ -130,7 +164,7 @@ public class SavingsPooledInterestPostingService {
|
||||
JOIN m_savings_account sa ON sa.id = d.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
LEFT JOIN m_group g ON g.id = sa.group_id
|
||||
WHERE d.savings_product_id = ? AND d.balance_date = ? AND d.is_posting_day = true
|
||||
WHERE d.savings_product_id = ? AND d.posting_date <= ?
|
||||
AND d.savings_transaction_id IS NULL
|
||||
AND d.cycle_cumulative_interest > 0
|
||||
AND COALESCE(c.office_id, g.office_id) IS NOT NULL
|
||||
@@ -168,16 +202,23 @@ public class SavingsPooledInterestPostingService {
|
||||
}
|
||||
|
||||
// The three accounts the bill passes through on its way to the members. Every one is read
|
||||
// from the product's own configuration — the standard Interest On Savings mapping, and the
|
||||
// two reserved names on the product's Accounting tab — so a bank on its own chart of
|
||||
// accounts is posted to the accounts it chose. No GL code is named in this class.
|
||||
// from the product's own configuration, so a bank on its own chart of accounts is posted to
|
||||
// the accounts it chose. No GL code is named in this class.
|
||||
//
|
||||
// Where a product leaves a reserved name unset the bill still has somewhere to go: the
|
||||
// payable falls back to the product's Savings Control (interest owed is a liability either
|
||||
// way), and the paid leg falls back to the payable, which collapses stage 2 into nothing and
|
||||
// leaves the two-legged posting a product without these mappings has always had.
|
||||
final Long interestExpenseGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_ON_SAVINGS);
|
||||
final Long payableGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||
// The product's Accounting tab is the authority, and it is consulted first for all three:
|
||||
// Interest On Savings for the expense, and the standard Interest Payable row for the
|
||||
// payable. Those are the rows a bank actually fills in on the product screen.
|
||||
//
|
||||
// The reserved names in the product's other GL mappings are consulted only after that, as a
|
||||
// fallback for a product configured before Interest Payable was mapped, and for the paid leg,
|
||||
// which has no standard row of its own.
|
||||
//
|
||||
// Where a product sets none of them the bill still has somewhere to go: the payable falls
|
||||
// back to the product's Savings Control (interest owed is a liability either way), and the
|
||||
// paid leg falls back to the payable, which collapses stage 2 and leaves the two-legged
|
||||
// posting a product without these mappings has always had.
|
||||
final Long payableGl = firstNonNull(optionalMappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_INTEREST_PAYABLE),
|
||||
namedGlAccount(productId, MAPPING_INTEREST_PAYABLE),
|
||||
mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL));
|
||||
final Long paidGl = firstNonNull(namedGlAccount(productId, MAPPING_INTEREST_PAID), payableGl);
|
||||
|
||||
@@ -185,24 +226,30 @@ public class SavingsPooledInterestPostingService {
|
||||
final String currency = (String) due.get(0).get("currency_code");
|
||||
final String poolReference = "SIP-" + productId + "-" + postingDate;
|
||||
|
||||
// Stage 1: the bank recognises what it owes. The expense is incurred and the whole product's
|
||||
// interest is parked in the payable, where it stands as a debt to the members until it
|
||||
// reaches them.
|
||||
journalEntry(officeId, currency, interestExpenseGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, false, poolReference, null);
|
||||
|
||||
// Stage 2: the debt moves to the account the payout is made from, so what is still merely
|
||||
// owed and what is on its way out are two separate balances rather than one.
|
||||
// Stage 1: the debt the accrual has been building moves out of the payable and into the
|
||||
// account the payout is made from, so what is still merely owed and what is on its way to
|
||||
// the members are two separate balances rather than one.
|
||||
//
|
||||
// The interest expense itself is not raised here. It is recognised as the interest accrues,
|
||||
// day by day, and posting is the settlement of that recognised debt — raising the expense
|
||||
// again at payout would count the same interest twice on the P&L.
|
||||
//
|
||||
// Skipped where the product names no separate paid account: both legs then resolve to the
|
||||
// payable and this would debit and credit the same account for the same amount — a pair of
|
||||
// entries that say nothing and clutter the ledger.
|
||||
if (!payableGl.equals(paidGl)) {
|
||||
// payable and this would debit and credit one account for the same amount, saying nothing.
|
||||
final boolean payableIsPaid = payableGl.equals(paidGl);
|
||||
final boolean stage1Applies = !payableIsPaid;
|
||||
if (stage1Applies) {
|
||||
journalEntry(officeId, currency, payableGl, postingDate, pooled, true, poolReference, null);
|
||||
journalEntry(officeId, currency, paidGl, postingDate, pooled, false, poolReference, null);
|
||||
}
|
||||
|
||||
// Stage 3: out to the members, one at a time.
|
||||
// Where stage 1 ran, the pool is now sitting in the paid account and that is what stage 2
|
||||
// draws down. Where it did not, the pool is still in the payable, so stage 2 has to draw
|
||||
// from there instead — debiting the paid account would take the money out of an account
|
||||
// nothing put it into, leaving the payable standing as a debt that was in fact settled.
|
||||
final Long payoutFromGl = stage1Applies ? paidGl : payableGl;
|
||||
|
||||
// Stage 2: out to the members, one at a time.
|
||||
BigDecimal distributed = BigDecimal.ZERO;
|
||||
for (final Map<String, Object> row : due) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("rounded_interest");
|
||||
@@ -217,7 +264,7 @@ public class SavingsPooledInterestPostingService {
|
||||
final long transactionId = createInterestPostingTransaction(accountId, accountOfficeId, postingDate, amount,
|
||||
newBalance);
|
||||
|
||||
journalEntry(accountOfficeId, currency, paidGl, postingDate, amount, true, poolReference,
|
||||
journalEntry(accountOfficeId, currency, payoutFromGl, postingDate, amount, true, poolReference,
|
||||
accountId);
|
||||
creditMemberSavingsControl(accountOfficeId, currency, productId, postingDate, amount, poolReference,
|
||||
accountId, transactionId);
|
||||
@@ -232,12 +279,22 @@ public class SavingsPooledInterestPostingService {
|
||||
WHERE id = ?
|
||||
""", amount, transactionId, runId, amount, ((Number) row.get("ledger_id")).longValue());
|
||||
|
||||
// Both interest columns move together, and that is not a duplicate.
|
||||
//
|
||||
// total_interest_earned_derived is the interest the account has earned to date;
|
||||
// total_interest_posted_derived is how much of that has been paid into the balance.
|
||||
// Screens and validate()'s own check read the unposted interest as earned - posted, so
|
||||
// raising only `posted` drives that difference negative: an account paid 26.52 with
|
||||
// `earned` still at zero reports its unposted interest as -26.52, and the account screen
|
||||
// showed exactly that. The accrual writes pending interest to the ledger and never to
|
||||
// `earned`, so this posting is the only place `earned` is realised.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_account
|
||||
SET account_balance_derived = COALESCE(account_balance_derived, 0) + ?,
|
||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?
|
||||
total_interest_posted_derived = COALESCE(total_interest_posted_derived, 0) + ?,
|
||||
total_interest_earned_derived = COALESCE(total_interest_earned_derived, 0) + ?
|
||||
WHERE id = ?
|
||||
""", amount, amount, accountId);
|
||||
""", amount, amount, amount, accountId);
|
||||
|
||||
distributed = distributed.add(amount);
|
||||
}
|
||||
@@ -297,7 +354,7 @@ public class SavingsPooledInterestPostingService {
|
||||
private void creditMemberSavingsControl(final long officeId, final String currency, final long productId,
|
||||
final LocalDate date, final BigDecimal amount, final String reference, final long accountId,
|
||||
final long transactionId) {
|
||||
final Long savingsControlGl = mappedGlAccount(productId, 2); // SAVINGS_CONTROL
|
||||
final Long savingsControlGl = mappedGlAccount(productId, GL_ACCOUNTS_FOR_SAVINGS_CONTROL);
|
||||
journalEntry(officeId, currency, savingsControlGl, date, amount, false, reference, accountId, transactionId);
|
||||
}
|
||||
|
||||
@@ -306,6 +363,18 @@ public class SavingsPooledInterestPostingService {
|
||||
journalEntry(officeId, currency, glAccountId, date, amount, debit, reference, savingsAccountId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* One journal entry leg.
|
||||
*
|
||||
* <p>
|
||||
* Fineract convention: {@code type_enum} 2 = DEBIT, 1 = CREDIT — {@code JournalEntryType}
|
||||
* declares {@code CREDIT(1)} first. This read {@code debit ? 1 : 2} and so signed every leg the
|
||||
* wrong way round: the member's savings-control credit was stored as a debit and the expense
|
||||
* debit as a credit. Nothing downstream caught it — the run's own pooled-equals-distributed check
|
||||
* compares Java values and never looks at {@code type_enum}, and migration 0531 disables the
|
||||
* stock posting job, so this is the only path writing savings interest to the ledger and there
|
||||
* was no correct one alongside to disagree with it.
|
||||
*/
|
||||
private void journalEntry(final long officeId, final String currency, final Long glAccountId, final LocalDate date,
|
||||
final BigDecimal amount, final boolean debit, final String reference, final Long savingsAccountId,
|
||||
final Long transactionId) {
|
||||
@@ -318,13 +387,6 @@ public class SavingsPooledInterestPostingService {
|
||||
created_on_utc, last_modified_on_utc, submitted_on_date)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, false, ?, false, false, 0, 0, ?, ?,
|
||||
CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, ?)
|
||||
// Fineract convention: type_enum 2 = DEBIT, 1 = CREDIT (JournalEntryType declares
|
||||
// CREDIT(1) first). This read `debit ? 1 : 2` and so signed every leg the wrong way
|
||||
// round: the member's savings-control credit was stored as a debit and the expense
|
||||
// debit as a credit. Nothing downstream caught it — the run's own pooled-equals-
|
||||
// distributed check compares Java values and never looks at type_enum, and migration
|
||||
// 0531 disables the stock posting job, so this is the only path writing savings
|
||||
// interest to the ledger and there was no correct one alongside to disagree with it.
|
||||
""", officeId, glAccountId, currency, reference, date, date, debit ? 2 : 1, amount,
|
||||
debit ? "Savings interest posting" : "Savings interest posted",
|
||||
savingsAccountId == null ? null : ENTITY_TYPE_SAVINGS, savingsAccountId, transactionId,
|
||||
@@ -351,16 +413,29 @@ public class SavingsPooledInterestPostingService {
|
||||
* products — the message has to say which product to go and configure.
|
||||
*/
|
||||
private Long mappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final Long found = optionalMappedGlAccount(productId, financialAccountType);
|
||||
if (found == null) {
|
||||
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
|
||||
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of the product's Accounting tab, or null where the bank has not filled it in.
|
||||
*
|
||||
* <p>
|
||||
* Separate from {@link #mappedGlAccount} because not every row is required: Interest Payable is
|
||||
* offered on the product screen but a product configured before it existed has none, and that
|
||||
* must fall through to the next choice rather than stopping the product from being paid.
|
||||
*/
|
||||
private Long optionalMappedGlAccount(final long productId, final int financialAccountType) {
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT gl_account_id FROM acc_product_mapping
|
||||
WHERE product_id = ? AND product_type = 2 AND financial_account_type = ?
|
||||
LIMIT 1
|
||||
""", Long.class, productId, financialAccountType);
|
||||
if (found.isEmpty()) {
|
||||
throw new IllegalStateException("Savings product " + productId + " has no GL account mapped for financial account type "
|
||||
+ financialAccountType + ". Set the product's Accounting tab before interest can be posted for it.");
|
||||
}
|
||||
return found.get(0);
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -403,7 +478,13 @@ public class SavingsPooledInterestPostingService {
|
||||
return found.isEmpty() ? null : found.get(0);
|
||||
}
|
||||
|
||||
private static Long firstNonNull(final Long preferred, final Long fallback) {
|
||||
return preferred != null ? preferred : fallback;
|
||||
/** The first configured account of those given, in order of preference. */
|
||||
private static Long firstNonNull(final Long... candidates) {
|
||||
for (final Long candidate : candidates) {
|
||||
if (candidate != null) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
+24
-6
@@ -31,6 +31,7 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||
@@ -290,16 +291,33 @@ public class SavingsDailyInterestApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Runs a day by hand, for a first backfill or after fixing a failed night. */
|
||||
/**
|
||||
* Runs a day by hand, for a first backfill or after fixing a failed night.
|
||||
*
|
||||
* Defaults to the business date, and refuses to run past it. Accruing or posting for a date the
|
||||
* bank has not reached writes interest into the books for days that have not happened: the
|
||||
* member's balance goes up today for a quarter that ends next month, and the journal entries
|
||||
* carry that future date. Worse, the business date can be moved back afterwards — for a test, or
|
||||
* because it was advanced by mistake — and then the ledger holds a posting dated beyond the day
|
||||
* the bank is working, which nothing reconciles against.
|
||||
*
|
||||
* To post for a later date, move the business date there first. That is the same order the
|
||||
* nightly run follows, and it keeps the books and the business date in step.
|
||||
*/
|
||||
@POST
|
||||
@Path("/run")
|
||||
public String run(@QueryParam("date") final String dateParam) {
|
||||
final LocalDate businessDate = dateParam == null || dateParam.isBlank()
|
||||
? LocalDate.now(DateUtils.getDateTimeZoneOfTenant())
|
||||
: LocalDate.parse(dateParam);
|
||||
orchestrator.runForDate(businessDate);
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate runFor = dateParam == null || dateParam.isBlank() ? businessDate : LocalDate.parse(dateParam);
|
||||
if (runFor.isAfter(businessDate)) {
|
||||
throw new PlatformDataIntegrityException("error.msg.savings.interest.run.after.business.date",
|
||||
"Cannot run savings interest for " + runFor + ": the business date is " + businessDate
|
||||
+ ". Move the business date forward first — interest cannot be accrued or posted for a day"
|
||||
+ " the bank has not reached.");
|
||||
}
|
||||
orchestrator.runForDate(runFor);
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("ranFor", businessDate.toString());
|
||||
result.put("ranFor", runFor.toString());
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.savings.service;
|
||||
|
||||
/**
|
||||
* The one definition of when a savings account was last known to be in live use, for the dormancy
|
||||
* ladder to measure from.
|
||||
*
|
||||
* <p>
|
||||
* It is written here once because it is read from six places — the nightly job's three selection
|
||||
* queries, the account read used by the counter screen, the account list, and the dormancy report
|
||||
* — and those places disagreeing is what makes an account inactive on one screen and active on the
|
||||
* next. A change to the rule belongs in this file and nowhere else.
|
||||
*
|
||||
* <h3>What counts</h3>
|
||||
*
|
||||
* <p>
|
||||
* A completed re-KYC, and nothing else. RBI's Master Direction on KYC makes periodic re-verification
|
||||
* the thing that keeps an account operative, and a lapsed account operable again only once the
|
||||
* customer's identity has been established afresh; a cash deposit at the counter is not that
|
||||
* verification, so it no longer revives an account here.
|
||||
*
|
||||
* <p>
|
||||
* The account's activation stands in for a client who has never been re-verified, so a newly opened
|
||||
* account does not start life a year behind.
|
||||
*/
|
||||
public final class DormancyClock {
|
||||
|
||||
private DormancyClock() {}
|
||||
|
||||
/**
|
||||
* SQL for the date the dormancy ladder measures from, as an expression over the aliases
|
||||
* {@code sa} (m_savings_account) and {@code c} (m_client).
|
||||
*
|
||||
* <p>
|
||||
* Correlated on the client rather than joined, so a caller can drop it into a SELECT list or a
|
||||
* WHERE clause without having to add a join it may already have — and so a client with no KYC
|
||||
* record at all falls back to the activation date rather than dropping out of the result.
|
||||
*/
|
||||
public static final String LAST_LIVE_ON = """
|
||||
GREATEST(
|
||||
sa.activatedon_date,
|
||||
COALESCE((SELECT MAX(k.verified_on) FROM m_client_kyc k
|
||||
WHERE k.client_id = sa.client_id AND k.verified_on IS NOT NULL),
|
||||
sa.activatedon_date))
|
||||
""";
|
||||
}
|
||||
+74
-7
@@ -63,9 +63,7 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
||||
public List<DormantAccountData> retrieveDormantAccounts(final Integer subStatus, final String officeName) {
|
||||
this.context.authenticatedUser();
|
||||
|
||||
final String lastCustomerTxn = "(select coalesce(max(sat.transaction_date), sa.activatedon_date) "
|
||||
+ "from m_savings_account_transaction sat where sat.savings_account_id = sa.id "
|
||||
+ "and sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2))";
|
||||
final String lastCustomerTxn = DormancyClock.LAST_LIVE_ON;
|
||||
|
||||
final StringBuilder sql = new StringBuilder();
|
||||
sql.append("select sa.id as accountId, sa.account_no as accountNo, sa.sub_status_enum as subStatus, ");
|
||||
@@ -85,19 +83,57 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
||||
// Left join: an escheated account has a remittance row only once the money has gone to RBI,
|
||||
// and the absence of one is exactly what the report has to show.
|
||||
sql.append("left join m_deaf_remittance dr on dr.savings_account_id = sa.id ");
|
||||
// Only the three states the ladder sets. A blocked or a plainly active account is not
|
||||
// dormant and does not belong on these reports.
|
||||
sql.append("where sa.sub_status_enum in (?, ?, ?) ");
|
||||
// An account belongs on these reports once the ladder has stamped it, or once the days
|
||||
// have earned it — whichever comes first.
|
||||
//
|
||||
// The stamp is applied by the nightly job, so an account that crossed a threshold today
|
||||
// still carries sub_status 0 until that job next runs, and the report showed nothing at
|
||||
// all: an account 23 days past its dormancy date was absent from a report whose whole
|
||||
// purpose is to list it. The elapsed count is already computed above for the daysIdle
|
||||
// column and uses the job's own definition of a customer transaction, so reading the
|
||||
// thresholds off the same product row here cannot disagree with what the job will do.
|
||||
//
|
||||
// Escheat is matched on the stamp alone. Unlike the other two it is not a label but a
|
||||
// movement of money — the job closes the account and takes the balance out — and listing
|
||||
// an account as escheated before that happened would report money as remitted that is
|
||||
// still sitting in the account.
|
||||
sql.append("where (sa.sub_status_enum in (?, ?, ?) ");
|
||||
sql.append(" or (sp.is_dormancy_tracking_active = true and sa.status_enum = 300 ");
|
||||
sql.append(" and (sp.days_to_inactive > 0 and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||
.append(" >= sp.days_to_inactive))) ");
|
||||
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add(DateUtils.getBusinessLocalDate());
|
||||
params.add(SUB_STATUS_INACTIVE);
|
||||
params.add(SUB_STATUS_DORMANT);
|
||||
params.add(SUB_STATUS_ESCHEAT);
|
||||
params.add(DateUtils.getBusinessLocalDate());
|
||||
|
||||
if (subStatus != null) {
|
||||
// Match the stamp or the stage the elapsed days have earned, so a stage filter picks up
|
||||
// the same accounts the unfiltered report shows.
|
||||
if (subStatus == SUB_STATUS_ESCHEAT) {
|
||||
sql.append("and sa.sub_status_enum = ? ");
|
||||
params.add(subStatus);
|
||||
} else if (subStatus == SUB_STATUS_DORMANT) {
|
||||
// Past the escheat threshold but not yet escheated still counts as dormant: the
|
||||
// account is dormant until the job closes it and moves the money, and it has to
|
||||
// appear somewhere until then.
|
||||
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_dormancy > 0 and ")
|
||||
.append(sqlGenerator.dateDiff("?", lastCustomerTxn)).append(" >= sp.days_to_dormancy)) ");
|
||||
params.add(subStatus);
|
||||
params.add(SUB_STATUS_ESCHEAT);
|
||||
params.add(DateUtils.getBusinessLocalDate());
|
||||
} else {
|
||||
sql.append("and (sa.sub_status_enum = ? or (sa.sub_status_enum < ? and sp.days_to_inactive > 0 and ")
|
||||
.append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||
.append(" >= sp.days_to_inactive and ").append(sqlGenerator.dateDiff("?", lastCustomerTxn))
|
||||
.append(" < sp.days_to_dormancy)) ");
|
||||
params.add(subStatus);
|
||||
params.add(SUB_STATUS_DORMANT);
|
||||
params.add(DateUtils.getBusinessLocalDate());
|
||||
params.add(DateUtils.getBusinessLocalDate());
|
||||
}
|
||||
}
|
||||
if (officeName != null && !officeName.isBlank()) {
|
||||
sql.append("and o.name = ? ");
|
||||
@@ -114,9 +150,19 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
||||
|
||||
@Override
|
||||
public DormantAccountData mapRow(final ResultSet rs, final int rowNum) throws SQLException {
|
||||
final int subStatus = rs.getInt("subStatus");
|
||||
final BigDecimal balance = rs.getBigDecimal("balance");
|
||||
final long daysIdle = rs.getLong("daysIdle");
|
||||
// The stage the elapsed days put the account at, in place of the stamp — the same rule
|
||||
// the WHERE clause selects on, so a row cannot appear on the report under one stage and
|
||||
// be labelled another.
|
||||
//
|
||||
// The days replace the stamp rather than merely raising it, because the stamp can also
|
||||
// be ahead: a customer transaction resets the clock, and an escheat that has not
|
||||
// happened yet leaves an account reported as dormant, which is what it still is.
|
||||
// Escheat itself is the exception and keeps its stamp, since the job has by then closed
|
||||
// the account and moved the balance out.
|
||||
final int stamped = rs.getInt("subStatus");
|
||||
final int subStatus = stamped >= SUB_STATUS_ESCHEAT ? stamped : earnedStage(rs, daysIdle);
|
||||
|
||||
return DormantAccountData.builder().accountId(rs.getLong("accountId")).accountNo(rs.getString("accountNo"))
|
||||
.clientId(rs.getLong("clientId")).clientName(rs.getString("clientName"))
|
||||
@@ -134,6 +180,27 @@ public class DormantAccountReadPlatformServiceImpl implements DormantAccountRead
|
||||
.deafRefundedOn(rs.getObject("deafRefundedOn", java.time.LocalDate.class)).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage the elapsed days have earned, whatever the job has stamped so far.
|
||||
*
|
||||
* <p>
|
||||
* Escheat is never earned this way. Unlike the other two it is not a label but a movement
|
||||
* of money — the job closes the account and takes the balance out — so an account only
|
||||
* counts as escheated once that has actually happened, and until then it is reported as
|
||||
* dormant, which is what it still is.
|
||||
*/
|
||||
private int earnedStage(final ResultSet rs, final long daysIdle) throws SQLException {
|
||||
final Long toDormancy = nullableLong(rs, "daysToDormancy");
|
||||
if (toDormancy != null && toDormancy > 0 && daysIdle >= toDormancy) {
|
||||
return SUB_STATUS_DORMANT;
|
||||
}
|
||||
final Long toInactive = nullableLong(rs, "daysToInactive");
|
||||
if (toInactive != null && toInactive > 0 && daysIdle >= toInactive) {
|
||||
return SUB_STATUS_INACTIVE;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private Long nullableLong(final ResultSet rs, final String column) throws SQLException {
|
||||
final long value = rs.getLong(column);
|
||||
return rs.wasNull() ? null : value;
|
||||
|
||||
+205
-23
@@ -22,9 +22,11 @@ import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsCons
|
||||
import static org.apache.fineract.infrastructure.core.domain.AuditableFieldsConstants.CREATED_DATE_DB_FIELD;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -66,6 +68,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionEnumData;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsAccrualData;
|
||||
import org.apache.fineract.portfolio.savings.data.SavingsProductData;
|
||||
import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestService;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountChargesPaidByData;
|
||||
@@ -83,6 +86,7 @@ import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.ResultSetExtractor;
|
||||
import org.springframework.jdbc.core.RowMapper;
|
||||
|
||||
@Slf4j
|
||||
public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountReadPlatformService {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
@@ -104,14 +108,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
private final SavingsAccountAssembler savingAccountAssembler;
|
||||
|
||||
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
|
||||
private final SavingsDailyInterestService savingsDailyInterestService;
|
||||
|
||||
public SavingsAccountReadPlatformServiceImpl(final PlatformSecurityContext context, final JdbcTemplate jdbcTemplate,
|
||||
final SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, ColumnValidator columnValidator,
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
|
||||
DatabaseSpecificSQLGenerator sqlGenerator, SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper,
|
||||
final SavingsDailyInterestService savingsDailyInterestService) {
|
||||
this.context = context;
|
||||
this.jdbcTemplate = jdbcTemplate;
|
||||
this.sqlGenerator = sqlGenerator;
|
||||
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
|
||||
this.savingsDailyInterestService = savingsDailyInterestService;
|
||||
this.transactionTemplateMapper = new SavingsAccountTransactionTemplateMapper();
|
||||
this.transactionsMapper = new SavingsAccountTransactionsMapper();
|
||||
this.savingsAccountTransactionsForBatchMapper = new SavingsAccountTransactionsForBatchMapper();
|
||||
@@ -122,6 +129,98 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
this.savingAccountAssembler = savingAccountAssembler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-accrues one account's interest ledger if it is not already at the business date.
|
||||
*
|
||||
* <p>
|
||||
* Guarded by a cheap read so the common case costs one indexed lookup and no write: through a
|
||||
* normal day the nightly run has already brought the row to today and there is nothing to do. The
|
||||
* refresh only fires when the row is genuinely behind — after the business date moves, or on an
|
||||
* account whose row the nightly run has not reached yet.
|
||||
*
|
||||
* <p>
|
||||
* A row dated <em>after</em> the business date is refreshed too, not skipped. That is the case
|
||||
* where the business date has been moved back, and the ledger has to come down to the earlier
|
||||
* date just as it goes up to a later one.
|
||||
*
|
||||
* <p>
|
||||
* Only savings accounts carry a row here; fixed and recurring deposits accrue through core
|
||||
* Fineract and are left alone by the refresh itself.
|
||||
*/
|
||||
/**
|
||||
* The dormancy sub-status the elapsed days have earned, whatever the column currently says.
|
||||
*
|
||||
* <p>
|
||||
* The nightly job is what stamps the column, so between an account crossing a threshold and
|
||||
* that job next running the column understates where the account actually stands — by a day in
|
||||
* the ordinary case, by longer after an outage or when the business date moves by more than a
|
||||
* day. Screens that read the column alone then tell the counter an account is fine when the
|
||||
* transaction is about to be refused.
|
||||
*
|
||||
* <p>
|
||||
* Mirrors the job's own thresholds, which are the product's, and is only ever used to raise the
|
||||
* reported status, never to lower one the job has already set.
|
||||
*/
|
||||
/**
|
||||
* The dormancy sub-status to report: the one the elapsed days say, in place of the one the
|
||||
* nightly job last stamped.
|
||||
*
|
||||
* <p>
|
||||
* Both directions, not only upward. Upward closes the gap between an account crossing a
|
||||
* threshold and the job next running. Downward matters because the stamp can outlive the
|
||||
* reason for it: a customer transaction resets the clock, and moving the business date back —
|
||||
* which is how this is tested — leaves a DORMANT stamp on an account only 379 days idle, which
|
||||
* is inactive. Either way the days are the truth and the stamp is a cache of it.
|
||||
*
|
||||
* <p>
|
||||
* Only the three dormancy states are touched. A blocked account, or one the job has escheated,
|
||||
* keeps what it has: those are not positions on this ladder, and escheat in particular has
|
||||
* moved money and closed the account, which no recomputation here can undo.
|
||||
*/
|
||||
private static int reportedDormancy(final Integer stamped, final LocalDate lastActive, final LocalDate businessDate,
|
||||
final Integer daysToInactive, final Integer daysToDormancy) {
|
||||
final int current = stamped == null ? SavingsAccountSubStatusEnum.NONE.getValue() : stamped;
|
||||
if (current >= SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||
return current;
|
||||
}
|
||||
return earnedSubStatus(lastActive, businessDate, daysToInactive, daysToDormancy);
|
||||
}
|
||||
|
||||
private static int earnedSubStatus(final LocalDate lastActive, final LocalDate businessDate, final Integer daysToInactive,
|
||||
final Integer daysToDormancy) {
|
||||
if (lastActive == null) {
|
||||
return SavingsAccountSubStatusEnum.NONE.getValue();
|
||||
}
|
||||
final long idle = ChronoUnit.DAYS.between(lastActive, businessDate);
|
||||
// Escheat is deliberately not anticipated. Unlike the other two it is not a label but a
|
||||
// movement of money — the job closes the account and transfers the balance to the escheat
|
||||
// liability — and reporting it before that has happened would show a closed account still
|
||||
// holding its balance. It stays whatever the job has actually done.
|
||||
if (daysToDormancy != null && daysToDormancy > 0 && idle >= daysToDormancy) {
|
||||
return SavingsAccountSubStatusEnum.DORMANT.getValue();
|
||||
}
|
||||
if (daysToInactive != null && daysToInactive > 0 && idle >= daysToInactive) {
|
||||
return SavingsAccountSubStatusEnum.INACTIVE.getValue();
|
||||
}
|
||||
return SavingsAccountSubStatusEnum.NONE.getValue();
|
||||
}
|
||||
|
||||
private void refreshInterestLedger(final Long accountId) {
|
||||
try {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
final List<LocalDate> ledgerDate = this.jdbcTemplate.queryForList(
|
||||
"select balance_date from m_savings_daily_interest where savings_account_id = ?", LocalDate.class, accountId);
|
||||
if (!ledgerDate.isEmpty() && DateUtils.isEqual(ledgerDate.get(0), businessDate)) {
|
||||
return;
|
||||
}
|
||||
this.savingsDailyInterestService.refreshAfterBalanceChange(accountId, businessDate);
|
||||
} catch (final RuntimeException e) {
|
||||
// Never break the read. The account must still open if its ledger cannot be refreshed;
|
||||
// the nightly run will pick it up.
|
||||
log.warn("Could not refresh the interest ledger for savings account {} before reading it", accountId, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Collection<SavingsAccountData> retrieveAllForLookup(final Long clientId) {
|
||||
|
||||
@@ -269,6 +368,21 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
@Override
|
||||
public SavingsAccountData retrieveOne(final Long accountId) {
|
||||
|
||||
// Bring the interest ledger up to the business date before reading it.
|
||||
//
|
||||
// The accrual normally runs once a night, so between the business date moving and the next
|
||||
// run the ledger is behind and this screen reports interest as of the older date. Moving the
|
||||
// business date to 30 September and opening the account showed the interest accrued to the
|
||||
// 9th, and moving it back showed the higher figure — the interest did not follow the date in
|
||||
// either direction, which is what made it look broken.
|
||||
//
|
||||
// refreshAfterBalanceChange is built for exactly this: it rewrites the account's row from the
|
||||
// day before it rather than from itself, so calling it repeatedly converges instead of
|
||||
// compounding, it creates the row if the account has none, and it never propagates a failure
|
||||
// to its caller. That last property is what makes it safe on a read path — a ledger that
|
||||
// cannot be refreshed must not stop the account from being displayed.
|
||||
refreshInterestLedger(accountId);
|
||||
|
||||
try {
|
||||
final String sql = "select " + this.savingAccountMapper.schema() + " where sa.id = ?";
|
||||
|
||||
@@ -379,7 +493,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
||||
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
||||
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
||||
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
|
||||
// Interest earned counts what the daily ledger has accrued but not yet paid.
|
||||
//
|
||||
// total_interest_earned_derived only moves when interest is posted, so between two
|
||||
// posting dates it says the account has earned nothing since the last payout. The screen
|
||||
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
|
||||
// (or a stale figure) on accounts the accrual had been building up for weeks — one
|
||||
// account had accrued 1,024.11 while the screen read 16.29.
|
||||
//
|
||||
// m_savings_daily_interest holds one row per account carrying the interest accrued in
|
||||
// the current cycle, which is exactly the unposted amount. Adding it to what has been
|
||||
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
|
||||
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
|
||||
// reading their own column exactly as before.
|
||||
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
|
||||
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
|
||||
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
|
||||
// Adding the cumulative figure whole would count that payment twice and report the
|
||||
// account as having earned 24.90.
|
||||
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
|
||||
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
|
||||
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
|
||||
+ "as totalInterestEarned, ");
|
||||
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
||||
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
||||
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
||||
@@ -397,11 +532,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
||||
sqlBuilder.append("sa.interest_posted_till_date as interestPostedTillDate, ");
|
||||
sqlBuilder.append("tg.id as taxGroupId, ");
|
||||
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
|
||||
sqlBuilder.append("from m_savings_account_transaction as sat ");
|
||||
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
|
||||
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
|
||||
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
|
||||
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
|
||||
sqlBuilder.append("sp.id as productId, ");
|
||||
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
||||
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
||||
@@ -521,8 +652,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
|
||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
||||
final boolean isDormancyTrackingActive = rs.getBoolean("isDormancyTrackingActive");
|
||||
final Integer numDaysToInactive = JdbcSupport.getInteger(rs, "daysToInactive");
|
||||
@@ -534,6 +664,20 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
|
||||
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
||||
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
||||
// Report the sub-status the elapsed days have earned, not only the one the
|
||||
// nightly job has got round to stamping.
|
||||
//
|
||||
// The job stamps DORMANT at midnight, so an account that crossed the
|
||||
// threshold during the day still reads as active until the next run — and
|
||||
// that is what the counter screen showed the clerk: "Status: Active", no
|
||||
// dormancy banner, on an account 23 days past its dormancy date. The clerk
|
||||
// filled in the whole slip before anything refused it.
|
||||
//
|
||||
// The thresholds and the last-active date are already read above for the
|
||||
// countdown, so this only reuses them. It is a read: the account row is not
|
||||
// written here, and the job remains what actually stamps the column.
|
||||
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
|
||||
numDaysToDormancy);
|
||||
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate,
|
||||
lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
||||
@@ -547,6 +691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
lastActiveTransactionDate.plusDays(numDaysToInactive));
|
||||
}
|
||||
}
|
||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||
final LocalDate approvedOnDate = JdbcSupport.getLocalDate(rs, "approvedOnDate");
|
||||
final LocalDate withdrawnOnDate = JdbcSupport.getLocalDate(rs, "withdrawnOnDate");
|
||||
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
||||
@@ -596,7 +741,17 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
||||
LocalDate lastInterestCalculationDate = null;
|
||||
if (totalInterestEarned != null) {
|
||||
// Floored at zero: this is unposted interest, and it cannot be negative.
|
||||
//
|
||||
// Earned and posted are two separate columns that a failed or reverted
|
||||
// posting can leave inconsistent, and the difference going negative used to
|
||||
// surface on the account screen as "Overdraft Interest Not Posted -25.98" —
|
||||
// the screen shows this same field under that label whenever it is negative,
|
||||
// on accounts with no overdraft at all.
|
||||
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
||||
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
|
||||
interestNotPosted = BigDecimal.ZERO;
|
||||
}
|
||||
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
||||
}
|
||||
|
||||
@@ -830,7 +985,28 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("sa.total_withdrawals_derived as totalWithdrawals, ");
|
||||
sqlBuilder.append("sa.total_withdrawal_fees_derived as totalWithdrawalFees, ");
|
||||
sqlBuilder.append("sa.total_annual_fees_derived as totalAnnualFees, ");
|
||||
sqlBuilder.append("sa.total_interest_earned_derived as totalInterestEarned, ");
|
||||
// Interest earned counts what the daily ledger has accrued but not yet paid.
|
||||
//
|
||||
// total_interest_earned_derived only moves when interest is posted, so between two
|
||||
// posting dates it says the account has earned nothing since the last payout. The screen
|
||||
// derives "Interest Earned Not Posted" as earned - posted, which therefore showed 0.00
|
||||
// (or a stale figure) on accounts the accrual had been building up for weeks — one
|
||||
// account had accrued 1,024.11 while the screen read 16.29.
|
||||
//
|
||||
// m_savings_daily_interest holds one row per account carrying the interest accrued in
|
||||
// the current cycle, which is exactly the unposted amount. Adding it to what has been
|
||||
// posted gives the true earned-to-date. The correlated subquery leaves accounts with no
|
||||
// ledger row — fixed and recurring deposits, which core Fineract accrues itself —
|
||||
// reading their own column exactly as before.
|
||||
// posted_amount is subtracted because the ledger keeps the cycle's cumulative interest
|
||||
// after it is paid: on a posting day the row carries cycle_cumulative_interest 12.45 and
|
||||
// posted_amount 12.45, and the same 12.45 is already in total_interest_posted_derived.
|
||||
// Adding the cumulative figure whole would count that payment twice and report the
|
||||
// account as having earned 24.90.
|
||||
sqlBuilder.append("(coalesce(sa.total_interest_posted_derived, 0) + coalesce((select d.cycle_cumulative_interest "
|
||||
+ "- coalesce(d.posted_amount, 0) from m_savings_daily_interest d where d.savings_account_id = sa.id), "
|
||||
+ "coalesce(sa.total_interest_earned_derived, 0) - coalesce(sa.total_interest_posted_derived, 0))) "
|
||||
+ "as totalInterestEarned, ");
|
||||
sqlBuilder.append("sa.total_interest_posted_derived as totalInterestPosted, ");
|
||||
sqlBuilder.append("sa.total_overdraft_interest_derived as totalOverdraftInterestDerived, ");
|
||||
sqlBuilder.append("sa.account_balance_derived as accountBalance, ");
|
||||
@@ -849,11 +1025,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sqlBuilder.append("sa.total_savings_amount_on_hold as onHoldAmount, ");
|
||||
sqlBuilder.append("sa.withdrawal_fee_for_transfer as withdrawalFeeForTransfers, ");
|
||||
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName, ");
|
||||
sqlBuilder.append("(select COALESCE(max(sat.transaction_date),sa.activatedon_date) ");
|
||||
sqlBuilder.append("from m_savings_account_transaction as sat ");
|
||||
sqlBuilder.append("where sat.is_reversed = false and sat.is_reversal = false ");
|
||||
sqlBuilder.append("and sat.transaction_type_enum in (1,2) ");
|
||||
sqlBuilder.append("and sat.savings_account_id = sa.id) as lastActiveTransactionDate, ");
|
||||
sqlBuilder.append(DormancyClock.LAST_LIVE_ON).append(" as lastActiveTransactionDate, ");
|
||||
sqlBuilder.append("sp.is_dormancy_tracking_active as isDormancyTrackingActive, ");
|
||||
sqlBuilder.append("sp.days_to_inactive as daysToInactive, ");
|
||||
sqlBuilder.append("sp.days_to_dormancy as daysToDormancy, ");
|
||||
@@ -948,8 +1120,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
final Integer statusEnum = JdbcSupport.getInteger(rs, "statusEnum");
|
||||
final SavingsAccountStatusEnumData status = SavingsEnumerations.status(statusEnum);
|
||||
|
||||
final Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||
Integer subStatusEnum = JdbcSupport.getInteger(rs, "subStatusEnum");
|
||||
final String reasonForBlock = rs.getString("reasonForBlock");
|
||||
|
||||
final LocalDate lastActiveTransactionDate = JdbcSupport.getLocalDate(rs, "lastActiveTransactionDate");
|
||||
@@ -963,6 +1134,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
|
||||
LocalDate currentDate = DateUtils.getBusinessLocalDate();
|
||||
if (isDormancyTrackingActive && statusEnum.equals(SavingsAccountStatusType.ACTIVE.getValue())) {
|
||||
// The elapsed days decide, not only the column the nightly job stamps — see
|
||||
// earnedSubStatus. This is the mapper behind the single-account read, which is what
|
||||
// the counter screen loads when a clerk searches an account, so without this it
|
||||
// showed "Active" on an account already past its dormancy date and the clerk filled
|
||||
// in the whole slip before anything refused it.
|
||||
subStatusEnum = reportedDormancy(subStatusEnum, lastActiveTransactionDate, currentDate, numDaysToInactive,
|
||||
numDaysToDormancy);
|
||||
if (subStatusEnum < SavingsAccountSubStatusEnum.ESCHEAT.getValue()) {
|
||||
daysToEscheat = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToEscheat));
|
||||
}
|
||||
@@ -973,6 +1151,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
daysToInactive = DateUtils.getExactDifferenceInDays(currentDate, lastActiveTransactionDate.plusDays(numDaysToInactive));
|
||||
}
|
||||
}
|
||||
final SavingsAccountSubStatusEnumData subStatus = SavingsEnumerations.subStatus(subStatusEnum);
|
||||
|
||||
final LocalDate submittedOnDate = JdbcSupport.getLocalDate(rs, "submittedOnDate");
|
||||
final String submittedByUsername = rs.getString("submittedByUsername");
|
||||
@@ -1092,7 +1271,13 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
BigDecimal interestNotPosted = BigDecimal.ZERO;
|
||||
LocalDate lastInterestCalculationDate = null;
|
||||
if (totalInterestEarned != null) {
|
||||
// Floored at zero, for the same reason as the summary mapper above: unposted
|
||||
// interest cannot be negative, and a negative value reaches the account screen
|
||||
// labelled "Overdraft Interest Not Posted" even on accounts with no overdraft.
|
||||
interestNotPosted = totalInterestEarned.subtract(totalInterestPosted).add(totalOverdraftInterestDerived);
|
||||
if (interestNotPosted.signum() < 0 && totalOverdraftInterestDerived.signum() == 0) {
|
||||
interestNotPosted = BigDecimal.ZERO;
|
||||
}
|
||||
lastInterestCalculationDate = JdbcSupport.getLocalDate(rs, "lastInterestCalculationDate");
|
||||
}
|
||||
|
||||
@@ -1484,10 +1669,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sql.append(" inner join m_savings_product as sp on (sa.product_id = sp.id and sp.is_dormancy_tracking_active = true) ");
|
||||
sql.append(" where sa.status_enum = 300 ");
|
||||
sql.append(" and sa.sub_status_enum = 0 ");
|
||||
String compareDate = "(select COALESCE(max(sat.transaction_date), sa.activatedon_date) "
|
||||
+ "from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false"
|
||||
+ " and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)";
|
||||
sql.append(" and ").append(sqlGenerator.dateDiff("?", compareDate)).append(" >= sp.days_to_inactive ");
|
||||
sql.append(" and ").append(sqlGenerator.dateDiff("?", DormancyClock.LAST_LIVE_ON)).append(" >= sp.days_to_inactive ");
|
||||
|
||||
try {
|
||||
ret = this.jdbcTemplate.queryForList(sql.toString(), new Object[] { tenantLocalDate }, Long.class);
|
||||
@@ -1509,7 +1691,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sql.append(" where sa.status_enum = 300 ");
|
||||
sql.append(" and sa.sub_status_enum = 100 ");
|
||||
sql.append(" and " + sqlGenerator.dateDiff("?",
|
||||
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
|
||||
DormancyClock.LAST_LIVE_ON)
|
||||
+ " ");
|
||||
sql.append(" >= sp.days_to_dormancy ");
|
||||
|
||||
@@ -1533,7 +1715,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
|
||||
sql.append(" where sa.status_enum = 300 ");
|
||||
sql.append(" and sa.sub_status_enum = 200 ");
|
||||
sql.append(" and " + sqlGenerator.dateDiff("?",
|
||||
"(select COALESCE(max(sat.transaction_date),sa.activatedon_date) from m_savings_account_transaction as sat where sat.is_reversed = false and sat.is_reversal = false and sat.transaction_type_enum in (1,2) and sat.savings_account_id = sa.id)")
|
||||
DormancyClock.LAST_LIVE_ON)
|
||||
+ " ");
|
||||
sql.append(" >= sp.days_to_escheat ");
|
||||
|
||||
|
||||
+6
-2
@@ -183,6 +183,7 @@ import org.apache.fineract.useradministration.domain.AppUserRepositoryWrapper;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.context.annotation.Scope;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
|
||||
@@ -426,9 +427,12 @@ public class SavingsConfiguration {
|
||||
@ConditionalOnMissingBean(SavingsAccountReadPlatformService.class)
|
||||
public SavingsAccountReadPlatformService savingsAccountReadPlatformService(PlatformSecurityContext context, JdbcTemplate jdbcTemplate,
|
||||
SavingsAccountAssembler savingAccountAssembler, PaginationHelper paginationHelper, DatabaseSpecificSQLGenerator sqlGenerator,
|
||||
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator) {
|
||||
SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper, ColumnValidator columnValidator,
|
||||
// @Lazy because the accrual service depends on beans from this same package: resolving
|
||||
// both eagerly closes a cycle and the context fails to start.
|
||||
@Lazy SavingsDailyInterestService savingsDailyInterestService) {
|
||||
return new SavingsAccountReadPlatformServiceImpl(context, jdbcTemplate, savingAccountAssembler, paginationHelper, columnValidator,
|
||||
sqlGenerator, savingsAccountRepositoryWrapper);
|
||||
sqlGenerator, savingsAccountRepositoryWrapper, savingsDailyInterestService);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -611,4 +611,5 @@
|
||||
<include file="parts/0533_backfill_savings_daily_interest_from_cycle_start.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0534_enable_business_date_advance_jobs.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0535_sync_business_date_to_system_date_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0538_separate_kyc_and_aml_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+27
@@ -52,4 +52,31 @@
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Move the identity counter past the id inserted above.
|
||||
|
||||
job.id is a Postgres identity column (GENERATED BY DEFAULT), so supplying an explicit id
|
||||
writes the row without advancing the sequence behind it. The counter is left at whatever it
|
||||
reached on its own, and the next insert that lets the identity assign an id then collides
|
||||
with this row: on a brand-new tenant the counter sits at 50 and the savings module's own
|
||||
"Add Accrual Transactions For Savings" seed (fineract-savings module, 2001, which correctly
|
||||
omits the id) fails with a duplicate key on job_pkey. One failing tenant aborts startup for
|
||||
every tenant.
|
||||
|
||||
An existing database never hit it because that job was seeded long before this one, so the
|
||||
counter had already moved past 50 by other means. Setting the counter from the table's own
|
||||
maximum is idempotent and safe to run against either.
|
||||
-->
|
||||
<changeSet id="0457_advance_job_identity_sequence" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_name = 'job' AND column_name = 'id' AND is_identity = 'YES'
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
SELECT setval(pg_get_serial_sequence('job', 'id'), (SELECT MAX(id) FROM job))
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
+57
@@ -236,4 +236,61 @@
|
||||
referencedTableName="m_savings_interest_posting_run" referencedColumnNames="id"/>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Bring the table to the one-row-per-account shape 0527 defines, for a database that has just
|
||||
created it here.
|
||||
|
||||
0527 replaces the (account, date) constraint above with one on account alone, and the service
|
||||
depends on it: SavingsDailyInterestService upserts with `ON CONFLICT (savings_account_id)`,
|
||||
which Postgres rejects outright — "there is no unique or exclusion constraint matching the ON
|
||||
CONFLICT specification" — when that exact constraint is absent. Every deposit, activation and
|
||||
accrual on the account fails with it.
|
||||
|
||||
0527 cannot do this itself on a new tenant: it is registered in changelog-tenant.xml, which
|
||||
db.changelog-master includes before the modules and before final-changelog-tenant.xml, where
|
||||
this file lives. So on a fresh database 0527 runs before the table exists and marks itself
|
||||
run, and the shape it was meant to apply never arrives. Applying it here, immediately after
|
||||
the table is created, is the only ordering that works for both cases: an existing database
|
||||
already has the constraint from 0527 and these changeSets mark themselves run.
|
||||
-->
|
||||
<changeSet id="0517_collapse_daily_interest_to_one_row_per_account" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
<not><indexExists indexName="uq_savings_daily_interest_account"/></not>
|
||||
</and>
|
||||
</preConditions>
|
||||
<!-- Keep the latest day per account, which is the row carrying the running cycle total. -->
|
||||
<sql>
|
||||
DELETE FROM m_savings_daily_interest d
|
||||
WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
|
||||
WHERE x.savings_account_id = d.savings_account_id)
|
||||
</sql>
|
||||
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
|
||||
constraintName="uq_savings_daily_interest_account"/>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0517_drop_daily_interest_account_date_constraint" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<indexExists indexName="uq_savings_daily_interest_account_date"/>
|
||||
</preConditions>
|
||||
<dropUniqueConstraint tableName="m_savings_daily_interest"
|
||||
constraintName="uq_savings_daily_interest_account_date"/>
|
||||
</changeSet>
|
||||
|
||||
<!-- With one row per account the date adds nothing to the product index; the posting query
|
||||
looks up by product alone. -->
|
||||
<changeSet id="0517_replace_product_date_index" author="CBS">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<indexExists indexName="idx_sdi_product_date"/>
|
||||
<not><indexExists indexName="idx_sdi_product"/></not>
|
||||
</and>
|
||||
</preConditions>
|
||||
<dropIndex indexName="idx_sdi_product_date" tableName="m_savings_daily_interest"/>
|
||||
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
|
||||
<column name="savings_product_id"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
+13
@@ -59,6 +59,19 @@
|
||||
unposted accruals are picked up on the first run rather than the second.
|
||||
-->
|
||||
<changeSet author="fineract" id="2">
|
||||
<!--
|
||||
Skipped when the table does not exist yet, which is the case on a brand-new database.
|
||||
|
||||
0518 creates it, but 0518 lives in final-changelog-tenant.xml, which db.changelog-master
|
||||
includes AFTER changelog-tenant.xml and all the modules — so on a fresh tenant this
|
||||
insert runs before the table is created and fails, and one failing tenant aborts startup
|
||||
for every tenant. On an existing database the table is already there and this seeds it as
|
||||
before. Nothing is lost by skipping: the orchestrator enrols any product missing from the
|
||||
schedule on its next run, which is what the comment above describes.
|
||||
-->
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_interest_posting_schedule"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_savings_interest_posting_schedule
|
||||
(savings_product_id, posting_period_enum, run_at_time, is_active)
|
||||
|
||||
+13
@@ -38,6 +38,19 @@
|
||||
start, so the day after a posting it begins again at that day's interest.
|
||||
-->
|
||||
<changeSet author="fineract" id="1">
|
||||
<!--
|
||||
Skipped until the ledger table exists.
|
||||
|
||||
0517 creates m_savings_daily_interest, but it lives in final-changelog-tenant.xml, which
|
||||
db.changelog-master includes after changelog-tenant.xml and all the modules. So on a
|
||||
brand-new tenant this view is defined before the table it reads, and the failure aborts
|
||||
startup for every tenant, not just the new one. On an existing database the table is
|
||||
already there and the view is created as before; on a fresh one it is picked up the next
|
||||
time migrations run, once final-changelog has created the table.
|
||||
-->
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
</preConditions>
|
||||
<createView viewName="v_savings_interest_summary" replaceIfExists="true">
|
||||
SELECT
|
||||
d.savings_account_id AS savings_account_id,
|
||||
|
||||
+16
@@ -60,6 +60,16 @@
|
||||
created. The latest day is kept: it is the one carrying the current cycle's running total.
|
||||
-->
|
||||
<changeSet author="fineract" id="2">
|
||||
<!--
|
||||
Skipped until the table exists. 0517 creates it, but 0517 lives in
|
||||
final-changelog-tenant.xml which db.changelog-master includes after this changelog and
|
||||
all the modules — so on a brand-new tenant every changeSet here runs before the table
|
||||
does, and the failure aborts startup for every tenant rather than just the new one.
|
||||
Nothing is lost by skipping: a table created later is created with these shapes already.
|
||||
-->
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
DELETE FROM m_savings_daily_interest d
|
||||
WHERE d.balance_date < (SELECT MAX(x.balance_date) FROM m_savings_daily_interest x
|
||||
@@ -68,6 +78,9 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="3">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
</preConditions>
|
||||
<addUniqueConstraint tableName="m_savings_daily_interest" columnNames="savings_account_id"
|
||||
constraintName="uq_savings_daily_interest_account"/>
|
||||
</changeSet>
|
||||
@@ -84,6 +97,9 @@
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="5">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_savings_daily_interest"/>
|
||||
</preConditions>
|
||||
<createIndex indexName="idx_sdi_product" tableName="m_savings_daily_interest">
|
||||
<column name="savings_product_id"/>
|
||||
</createIndex>
|
||||
|
||||
+54
@@ -110,4 +110,58 @@
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Create the rows the update above assumes, and switch the feature on.
|
||||
|
||||
m_business_date is created empty by 0015_add_business_date.xml and nothing seeds it: the
|
||||
rows only ever appear when somebody sets the date through the API. So on a brand-new tenant
|
||||
changeSet 3 above updates nothing, the table stays empty, and enable-business-date stays
|
||||
false — which means the whole platform silently falls back to the server clock. The business
|
||||
date then cannot be advanced by the sync job this file registers, cannot be held back for a
|
||||
branch still working yesterday, and every screen showing "business date" is showing the wall
|
||||
clock instead.
|
||||
|
||||
Seeded at CURRENT_DATE, which is the only sensible starting point for a tenant being created
|
||||
today, and guarded per row so an existing tenant that already has them is left alone.
|
||||
-->
|
||||
<changeSet author="fineract" id="5">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_business_date WHERE type = 'BUSINESS_DATE'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_business_date
|
||||
(type, date, created_by, created_date, version, last_modified_by,
|
||||
lastmodified_date, created_on_utc, last_modified_on_utc)
|
||||
VALUES ('BUSINESS_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW());
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="fineract" id="6">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_business_date WHERE type = 'COB_DATE'</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_business_date
|
||||
(type, date, created_by, created_date, version, last_modified_by,
|
||||
lastmodified_date, created_on_utc, last_modified_on_utc)
|
||||
VALUES ('COB_DATE', CURRENT_DATE, 1, NOW(), 1, 1, NOW(), NOW(), NOW());
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Without this the rows above are ignored: DateUtils.getBusinessLocalDate() reads the tenant
|
||||
clock unless this flag is on, so the sync job would move a date nothing consults.
|
||||
-->
|
||||
<changeSet author="fineract" id="7">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
SELECT COUNT(*) FROM c_configuration WHERE name = 'enable-business-date' AND enabled = false
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<update tableName="c_configuration">
|
||||
<column name="enabled" valueBoolean="true"/>
|
||||
<where>name = 'enable-business-date'</where>
|
||||
</update>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements. See the NOTICE file
|
||||
distributed with this work for additional information
|
||||
regarding copyright ownership. The ASF licenses this file
|
||||
to you under the Apache License, Version 2.0 (the
|
||||
"License"); you may not use this file except in compliance
|
||||
with the License. You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing,
|
||||
software distributed under the License is distributed on an
|
||||
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
KIND, either express or implied. See the License for the
|
||||
specific language governing permissions and limitations
|
||||
under the License.
|
||||
-->
|
||||
<!--
|
||||
m_client_risk_rule holds two different kinds of rule that were never distinguishable:
|
||||
|
||||
KYC rules (0428) - profile-driven. Occupation / income / turnover decide a client's risk
|
||||
category at onboarding. RBI Master Direction on KYC, Section 12.
|
||||
AML rules (0430) - behaviour-driven. 221 transaction-monitoring scenarios ("Large cash
|
||||
transaction", "Additional RFIs Rule - 91", ...) that must only fire off
|
||||
posted transactions, surfacing in m_aml_alert. RBI MD Section 13.
|
||||
|
||||
The KYC risk engine was separating them by inference - "a rule with no occupation and no
|
||||
income/turnover criteria must be an AML rule, so skip it". Migration 0448 broke that twice over:
|
||||
|
||||
- it set every NULL income/turnover column to 0.0, so no rule looks criteria-less any more;
|
||||
- it applied a Medium-income band (50k-200k monthly, 6L-24L annual) to
|
||||
"order_position BETWEEN 90 AND 240". That range was written for 0428's KYC occupation
|
||||
rules, but order_position is not unique across the two seeds - it also caught 0430's
|
||||
AML rules 91-94.
|
||||
|
||||
Net effect: a salaried client earning 1L monthly matched "Additional RFIs Rule - 91" and was
|
||||
categorised Medium Risk with aml_check_status FLAGGED, ahead of the genuine income rule at
|
||||
order_position 250. An unfired transaction-monitoring scenario was deciding a KYC category and
|
||||
raising an AML flag on a customer who had transacted nothing - contrary to Sections 12 and 13.
|
||||
|
||||
Inference cannot be repaired in place. aml_check_status IS NOT NULL is not usable as the
|
||||
discriminator either: KycRiskRulesWritePlatformServiceImpl defaults it to 'CLEAR' on create, so
|
||||
every rule added through the Rules Master screen carries a non-null value regardless of kind.
|
||||
This changeset therefore records the kind explicitly, so a rule declares what it is instead of
|
||||
being guessed at, and any rule added later is classified by its author rather than by a pattern
|
||||
in its criteria columns.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.13.xsd">
|
||||
|
||||
<changeSet author="fineract" id="0538-1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_risk_rule" columnName="rule_kind"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_risk_rule">
|
||||
<column name="rule_kind" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Classify the existing catalog by how each row was written, not by what its criteria columns
|
||||
say now - 0448 has already overwritten those, and not by rule_name either, because the AML
|
||||
catalog runs to 219 rows across families that share no common wording ("Afghan Drug Business
|
||||
- ...", "Credit Card Monitoring - ...", "Trade Based Money Laundering - ...", "FICN - ...").
|
||||
|
||||
The reliable signal is the disposition columns. 0430 sets aml_check_status on every row it
|
||||
inserts - FLAGGED for a reportable scenario, BLOCKED for the terrorist-financing and FICN
|
||||
ones. 0428's KYC insert never names the column at all, so its 47 rows hold NULL.
|
||||
|
||||
Only migration 0437 muddies this, having set aml_check_status = 'CLEAR' on ids 269-279 (rows
|
||||
that sit past both seeds - added through the Rules Master screen). Testing for the two
|
||||
values 0430 actually writes, rather than for "not null", leaves those rows out and lets them
|
||||
fall through to KYC below, which is what they are.
|
||||
-->
|
||||
<changeSet author="fineract" id="0538-2">
|
||||
<sql>
|
||||
-- AML transaction-monitoring rules: anything carrying a live-transaction counterpart
|
||||
-- in m_aml_transaction_rule is behaviour-driven by definition.
|
||||
UPDATE m_client_risk_rule r
|
||||
SET rule_kind = 'AML'
|
||||
WHERE EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id);
|
||||
|
||||
-- The rest of 0430's catalog: seeded with a non-CLEAR disposition, and constraining no
|
||||
-- customer attribute. Both halves must hold - a bank's own high-risk KYC rule could
|
||||
-- legitimately be authored as FLAGGED, but it would name an occupation or an income
|
||||
-- band, so the second test keeps it in the KYC family.
|
||||
UPDATE m_client_risk_rule
|
||||
SET rule_kind = 'AML'
|
||||
WHERE rule_kind IS NULL
|
||||
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
|
||||
AND legal_form_id IS NULL
|
||||
AND occupation_category_cv_id IS NULL
|
||||
AND occupation_cv_id IS NULL
|
||||
AND COALESCE(min_monthly_income, 0) = 0
|
||||
AND COALESCE(max_monthly_income, 0) = 0
|
||||
AND COALESCE(min_annual_income, 0) = 0
|
||||
AND COALESCE(max_annual_income, 0) = 0
|
||||
AND COALESCE(min_annual_turnover, 0) = 0
|
||||
AND COALESCE(max_annual_turnover, 0) = 0;
|
||||
|
||||
-- The AML rows that 0448 additionally stamped with an income band, which is what put
|
||||
-- them beyond the criteria test above.
|
||||
--
|
||||
-- 0448 applied a 50k-200k monthly / 6L-24L annual band to "order_position BETWEEN 90
|
||||
-- AND 240", intending 0428's occupation rules. order_position is not unique across the
|
||||
-- two seeds, so it landed on roughly 140 of 0430's scenarios as well - "Additional RFIs
|
||||
-- Rule - 91" (the row that categorised a salaried employee as Medium Risk and flagged
|
||||
-- them) along with the whole "Account Monitoring - ...", "Afghan Drug Business - ...",
|
||||
-- "Credit Card Monitoring - ..." and "Customer Due Diligence - ..." families.
|
||||
--
|
||||
-- Those rows are identifiable precisely: they carry that exact band, still constrain no
|
||||
-- occupation or legal form, and hold a disposition only 0430 writes. A genuine KYC rule
|
||||
-- from 0428 in the same order_position range always names an occupation or occupation
|
||||
-- category (NRI, Business Owner, Chartered Accountant, ...), so it cannot match here;
|
||||
-- 0428's two bare income rules sit at 250 and 260, outside the band 0448 applied.
|
||||
UPDATE m_client_risk_rule
|
||||
SET rule_kind = 'AML'
|
||||
WHERE rule_kind IS NULL
|
||||
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
|
||||
AND legal_form_id IS NULL
|
||||
AND occupation_category_cv_id IS NULL
|
||||
AND occupation_cv_id IS NULL
|
||||
AND min_monthly_income = 50000
|
||||
AND max_monthly_income = 200000
|
||||
AND min_annual_income = 600000
|
||||
AND max_annual_income = 2400000
|
||||
AND COALESCE(min_annual_turnover, 0) = 0
|
||||
AND COALESCE(max_annual_turnover, 0) = 0;
|
||||
|
||||
-- Likewise the 0430 rows caught by 0448's other two bands, for completeness: the
|
||||
-- high-risk-occupation band at order_position 20/30/40/50 and the low-risk band at
|
||||
-- 280-450. Same reasoning - a real 0428 rule in those ranges names an occupation.
|
||||
UPDATE m_client_risk_rule
|
||||
SET rule_kind = 'AML'
|
||||
WHERE rule_kind IS NULL
|
||||
AND aml_check_status IN ('FLAGGED', 'BLOCKED')
|
||||
AND legal_form_id IS NULL
|
||||
AND occupation_category_cv_id IS NULL
|
||||
AND occupation_cv_id IS NULL
|
||||
AND (
|
||||
(min_monthly_income = 200000 AND min_annual_income = 2400000)
|
||||
OR (min_monthly_income = 0 AND max_monthly_income = 50000
|
||||
AND min_annual_income = 0 AND max_annual_income = 600000)
|
||||
);
|
||||
|
||||
-- Everything left is a KYC profile rule (0428, plus anything a bank has added since).
|
||||
UPDATE m_client_risk_rule SET rule_kind = 'KYC' WHERE rule_kind IS NULL;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Undo 0448's misapplied income band on the AML rows. Leaving 0.0 in place would be harmless
|
||||
now that rule_kind excludes them from matching, but a criteria-less scenario rule showing a
|
||||
50k-200k income band on the AML Rules Master screen reads as a real threshold to whoever
|
||||
reviews the catalog, which is misleading in an RBI inspection.
|
||||
-->
|
||||
<changeSet author="fineract" id="0538-3">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET min_monthly_income = null,
|
||||
max_monthly_income = null,
|
||||
min_annual_income = null,
|
||||
max_annual_income = null,
|
||||
min_annual_turnover = null,
|
||||
max_annual_turnover = null
|
||||
WHERE rule_kind = 'AML';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
0448 also flattened the genuine KYC rules' unset bounds to 0.0. The matching code treats a
|
||||
0 bound as "not configured" (a `> 0` guard accompanies every comparison), so this is
|
||||
cosmetic rather than behavioural - but the same reasoning as above applies: a rule whose
|
||||
real meaning is "no upper limit" should not display 0 as its ceiling.
|
||||
-->
|
||||
<changeSet author="fineract" id="0538-4">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule SET min_monthly_income = null WHERE rule_kind = 'KYC' AND min_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_monthly_income = null WHERE rule_kind = 'KYC' AND max_monthly_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_income = null WHERE rule_kind = 'KYC' AND min_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_income = null WHERE rule_kind = 'KYC' AND max_annual_income = 0;
|
||||
UPDATE m_client_risk_rule SET min_annual_turnover = null WHERE rule_kind = 'KYC' AND min_annual_turnover = 0;
|
||||
UPDATE m_client_risk_rule SET max_annual_turnover = null WHERE rule_kind = 'KYC' AND max_annual_turnover = 0;
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
RBI MD Section 13 reserves an AML flag for behaviour actually observed. A profile rule must
|
||||
never pre-flag a customer at onboarding, so the KYC catalog records CLEAR; a real alert is
|
||||
raised against a posted transaction in m_aml_alert, not here.
|
||||
-->
|
||||
<changeSet author="fineract" id="0538-5">
|
||||
<sql>
|
||||
UPDATE m_client_risk_rule
|
||||
SET aml_check_status = 'CLEAR'
|
||||
WHERE rule_kind = 'KYC' AND aml_check_status = 'FLAGGED';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+115
-8
@@ -61,6 +61,7 @@ import java.math.MathContext;
|
||||
import java.time.LocalDate;
|
||||
import java.time.YearMonth;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
@@ -1434,10 +1435,17 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
addTransaction(transaction);
|
||||
}
|
||||
|
||||
if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue())
|
||||
|| this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) {
|
||||
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
|
||||
}
|
||||
// Nothing on the dormancy ladder is cleared by a transaction — not dormancy, and not
|
||||
// inactivity either.
|
||||
//
|
||||
// RBI's Master Direction makes periodic re-verification the thing that keeps an account
|
||||
// operative, and a lapsed one operable again only once the customer's identity has been
|
||||
// established afresh. A cash-in at the counter is not that verification, so it cannot
|
||||
// stand in for it: the sub-status is cleared by a completed re-KYC and by nothing else
|
||||
// (see reviveOnReKyc), and DormancyClock measures the ladder from that same re-KYC.
|
||||
//
|
||||
// Both used to be cleared here, which meant a ₹100 deposit quietly did the work the
|
||||
// branch was supposed to do at the counter.
|
||||
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction,
|
||||
@@ -1512,6 +1520,37 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
// A dormant account does not pay out until someone has looked at it.
|
||||
//
|
||||
// Dormancy means nobody has operated the account for the product's dormancy period — two
|
||||
// years here — and an account nobody has touched for two years is the one an impersonator
|
||||
// reaches for, because the real customer is not watching it. RBI's Master Direction on KYC
|
||||
// requires the operations of such an account to be allowed only after the customer's
|
||||
// identity is established afresh, so the debit is stopped and the account is revived only
|
||||
// by a completed re-KYC.
|
||||
//
|
||||
// Credits are deliberately not stopped: a deposit puts money in, cannot be a fraud against
|
||||
// the customer, and is the customer coming back.
|
||||
//
|
||||
// The elapsed days are counted here rather than trusting the sub-status alone. The nightly
|
||||
// job is what stamps DORMANT, so between an account passing the threshold and that job next
|
||||
// running the flag still says the account is fine — and a withdrawal in that window used to
|
||||
// go straight through. It is a real gap: the job can be behind after an outage, or the
|
||||
// business date can move by more than a day.
|
||||
if (isDormantByElapsedDays()) {
|
||||
final String defaultUserMessage = "Withdrawal is not allowed. The account is dormant — no customer-initiated"
|
||||
+ " transaction for " + dormancyPeriodDescription() + ". It must be reactivated after the customer's"
|
||||
+ " identity is re-verified.";
|
||||
final ApiParameterError error = ApiParameterError.parameterError(
|
||||
"error.msg.savingsaccount.transaction.withdrawals.blocked.account.dormant", defaultUserMessage, "transactionDate",
|
||||
transactionDTO.getTransactionDate().format(transactionDTO.getFormatter()));
|
||||
|
||||
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
|
||||
dataValidationErrors.add(error);
|
||||
|
||||
throw new PlatformApiDataValidationException(dataValidationErrors);
|
||||
}
|
||||
|
||||
if (DateUtils.isDateInTheFuture(transactionDTO.getTransactionDate())) {
|
||||
final String defaultUserMessage = "Transaction date cannot be in the future.";
|
||||
final ApiParameterError error = ApiParameterError.parameterError("error.msg.savingsaccount.transaction.in.the.future",
|
||||
@@ -1570,10 +1609,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
addTransaction(transaction);
|
||||
}
|
||||
|
||||
if (this.sub_status.equals(SavingsAccountSubStatusEnum.INACTIVE.getValue())
|
||||
|| this.sub_status.equals(SavingsAccountSubStatusEnum.DORMANT.getValue())) {
|
||||
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
|
||||
}
|
||||
// A withdrawal clears nothing on the dormancy ladder either — see the deposit path for why.
|
||||
if (backdatedTxnsAllowedTill) {
|
||||
this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, transaction,
|
||||
this.savingsAccountTransactions);
|
||||
@@ -1581,6 +1617,52 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
return transaction;
|
||||
}
|
||||
|
||||
/**
|
||||
* How long the product says an account must sit untouched before it is dormant, for the message
|
||||
* the customer is shown. Read from the product so the figure quoted is the one actually applied.
|
||||
*/
|
||||
private String dormancyPeriodDescription() {
|
||||
final Long days = this.product == null ? null : this.product.getDaysToDormancy();
|
||||
if (days == null || days <= 0) {
|
||||
return "the product's dormancy period";
|
||||
}
|
||||
final long years = days / 365;
|
||||
return years >= 1 ? years + (years == 1 ? " year" : " years") : days + " days";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this account is dormant, by the sub-status the nightly job stamps or by the days that
|
||||
* have actually elapsed since the customer last operated it.
|
||||
*
|
||||
* <p>
|
||||
* The elapsed count is what closes the window between an account crossing the dormancy
|
||||
* threshold and the nightly job next running: until that job runs the sub-status still says the
|
||||
* account is fine, and a withdrawal in that window went straight through. The job can be a day
|
||||
* behind after an outage, and a business date moved forward by more than a day skips the runs
|
||||
* in between entirely.
|
||||
*
|
||||
* <p>
|
||||
* The count mirrors the job's own query exactly, so the two can never disagree about whether an
|
||||
* account is dormant: only deposits and withdrawals count as the customer operating the
|
||||
* account — interest and charges are the bank's own movements, not the customer's — reversed
|
||||
* transactions are ignored, and an account with no such transaction is measured from its
|
||||
* activation. Dormancy tracking being switched off on the product turns this off with it.
|
||||
*/
|
||||
/**
|
||||
* Whether this account is dormant, from the sub-status the nightly job stamped.
|
||||
*
|
||||
* <p>
|
||||
* The elapsed days are not recounted here, though the read services and the clerk's withdrawal
|
||||
* check both do. The clock now runs from the customer's last completed re-KYC — see
|
||||
* DormancyClock — and this module cannot see a client's KYC record, so counting here would use
|
||||
* transactions and give a different answer from every other place that asks. A guard that
|
||||
* disagrees with the screen the officer just read is worse than one that lags the job by a
|
||||
* night, and the clerk's own check catches that gap before a request is ever raised.
|
||||
*/
|
||||
private boolean isDormantByElapsedDays() {
|
||||
return SavingsAccountSubStatusEnum.DORMANT.hasStateOf(SavingsAccountSubStatusEnum.fromInt(this.sub_status));
|
||||
}
|
||||
|
||||
public BigDecimal calculateWithdrawalFee(final BigDecimal transactionAmount) {
|
||||
BigDecimal result = BigDecimal.ZERO;
|
||||
if (isWithdrawalFeeApplicableForTransfer()) {
|
||||
@@ -4278,6 +4360,31 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revives a dormant account, on the strength of a completed re-KYC.
|
||||
*
|
||||
* <p>
|
||||
* RBI's Master Direction allows a dormant account to be operated again only once the customer's
|
||||
* identity has been established afresh. A completed re-KYC is that verification, so it is the
|
||||
* one thing that lifts dormancy — a transaction does not, which is why {@link #withdraw} refuses
|
||||
* on a dormant account and a deposit no longer revives one.
|
||||
*
|
||||
* <p>
|
||||
* Called from the re-KYC completion itself rather than from the nightly expiry sweep: the sweep
|
||||
* runs over every customer whose KYC is merely current, and reviving there would reopen a
|
||||
* dormant account on the next nightly run without anyone having verified anything.
|
||||
*
|
||||
* @return true if the sub-status was changed
|
||||
*/
|
||||
public boolean reviveOnReKyc() {
|
||||
final SavingsAccountSubStatusEnum current = SavingsAccountSubStatusEnum.fromInt(this.sub_status);
|
||||
if (SavingsAccountSubStatusEnum.DORMANT.hasStateOf(current) || SavingsAccountSubStatusEnum.INACTIVE.hasStateOf(current)) {
|
||||
this.sub_status = SavingsAccountSubStatusEnum.NONE.getValue();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
public void validateForAccountBlock() {
|
||||
final SavingsAccountSubStatusEnum currentSubStatus = SavingsAccountSubStatusEnum.fromInt(this.getSubStatus());
|
||||
if (SavingsAccountSubStatusEnum.BLOCK.hasStateOf(currentSubStatus)) {
|
||||
|
||||
+48
@@ -293,4 +293,52 @@
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
Clear the income GL these charges were seeded with, and let each product's own mapping decide.
|
||||
|
||||
Every insert above picks its income account with
|
||||
|
||||
SELECT id FROM acc_gl_account
|
||||
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
|
||||
ORDER BY id LIMIT 1
|
||||
|
||||
which is "whichever income account happens to have the lowest id". That is not a choice, it is an
|
||||
accident of the order the chart of accounts was created in, and it lands somewhere different on
|
||||
every database: on one tenant it resolved to 400101 INCOME FROM LOAN, so savings facility charges
|
||||
were booked as loan income; on a tenant whose chart was seeded in another order it resolved to
|
||||
400103 DD Issuance Charges Income, so an ATM charge was booked against demand drafts. Neither is
|
||||
the account a bank would name for an ATM fee, and no screen shows the reader which one was picked.
|
||||
|
||||
A charge's own account overrides the product's Income from Fees mapping — see
|
||||
AccountingProcessorHelper.getLinkedGLAccountForSavingsCharges, which reads the charge's account
|
||||
first and only falls back to the product mapping when it is null. So while these values stand, the
|
||||
account an administrator chose on the product's Accounting tab is silently ignored. Setting them
|
||||
null restores that mapping as the answer, which is where a bank configures this and the only place
|
||||
it can be seen and changed.
|
||||
|
||||
Only rows still carrying the seeded value are touched: the account is compared against what that
|
||||
same query resolves to now, so a charge an administrator has since pointed at a real account keeps
|
||||
it. 0500_reset_facility_charge_mappings_to_unmapped.xml already decided these seeds are not an
|
||||
administrator's choice and deactivated the mappings for the same reason.
|
||||
-->
|
||||
<changeSet id="2011-19" author="cbs-nitesh">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_charge"/>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_charge
|
||||
SET income_or_liability_account_id = NULL
|
||||
WHERE income_or_liability_account_id = (
|
||||
SELECT id FROM acc_gl_account
|
||||
WHERE classification_enum = 4 AND account_usage = 1 AND disabled = false
|
||||
ORDER BY id LIMIT 1)
|
||||
AND name IN ('ATM Facility Charge', 'ATM Withdrawal Charge', 'UPI Facility Charge',
|
||||
'POS Transaction Charge', 'Cheque Book Charge', 'IMPS Facility Charge',
|
||||
'SMS Alert Monthly Charge', 'BBPS Facility Charge', 'Insurance Cover Charge',
|
||||
'BEPG Facility Charge', 'NEFT Facility Charge', 'RTGS Facility Charge',
|
||||
'Inter-Bank Cap Charge', 'Withdrawal Facility Charge', 'Deposit KYC Charge',
|
||||
'Virtual Debit Card Charge');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user