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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user