feat: AML alert rule disposition, Client Behaviour Window evaluation, Risk Policy parameters & Board Resolution register
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (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 3 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (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 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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 - 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 - 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 - 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-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-09-16 12:50:43 +05:30
parent af43944699
commit 521d61d6b7
65 changed files with 4928 additions and 32 deletions
@@ -72,6 +72,7 @@ public enum JobName {
OUTWARD_CLEARING_BATCH_SEND("CTS Outward Clearing Batch Send"), //
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
; //
private final String name;
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "disposition", length = 20)
private String disposition;
/**
* The other rules this same transaction matched, comma-separated.
*
* <p>
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
* stopping them clearing one while escalating another. The rules that did not win are kept here
* so the case file still shows everything that fired.
*/
@Column(name = "also_matched_rules", length = 500)
private String alsoMatchedRules;
/**
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
*
* <p>
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
*/
@Column(name = "highest_severity")
private Integer highestSeverity;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
@@ -94,6 +118,24 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return alert;
}
public String getAlsoMatchedRules() {
return this.alsoMatchedRules;
}
public Integer getHighestSeverity() {
return this.highestSeverity;
}
public void recordHighestSeverity(final Integer severity) {
this.highestSeverity = severity;
}
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
public void recordAlsoMatchedRules(final String ruleCodes) {
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
@@ -44,4 +44,16 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
/**
* A customer's still-open alerts inside the observation window, oldest first.
*
* <p>
* What a recurring-behaviour review acts on. Restricted to RAISED and UNDER_REVIEW because a decision about the
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
* that a partial failure leaves the longest-standing ones dealt with.
*/
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
+ "AND a.status IN ('RAISED', 'UNDER_REVIEW') ORDER BY a.raisedOn ASC")
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
}
@@ -0,0 +1,163 @@
/**
* 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One rule a transaction matched, and what the officer decided about that rule.
*
* <p>
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
* observation or sends the other five to an STR they do not belong in.
*
* <p>
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
* afterwards must not rewrite what the officer was looking at when they decided.
*/
@Entity
@Table(name = "m_aml_alert_rule")
public class AmlAlertRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "rule_code", nullable = false, length = 100)
private String ruleCode;
@Column(name = "risk_category")
private Integer riskCategory;
@Column(name = "is_governing", nullable = false)
private boolean governing;
@Column(name = "disposition", length = 20)
private String disposition;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
/**
* Whether the officer named this rule, as against it being closed to keep the case consistent.
*
* <p>
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
* the substance of the finding.
*/
@Column(name = "directly_decided", nullable = false)
private boolean directlyDecided;
protected AmlAlertRule() {}
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
final boolean governing) {
final AmlAlertRule finding = new AmlAlertRule();
finding.alert = alert;
finding.ruleCode = ruleCode;
finding.riskCategory = riskCategory;
finding.governing = governing;
return finding;
}
/**
* @param directlyDecided
* true when the officer named this rule; false when it was closed alongside one they named
*/
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
final boolean directlyDecided) {
this.disposition = disposition.name();
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.directlyDecided = directlyDecided;
}
public boolean isDirectlyDecided() {
return this.directlyDecided;
}
public boolean isDecided() {
return this.disposition != null;
}
public boolean isEscalated() {
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
}
public Long getId() {
return this.id;
}
public AmlAlert getAlert() {
return this.alert;
}
public String getRuleCode() {
return this.ruleCode;
}
public Integer getRiskCategory() {
return this.riskCategory;
}
public boolean isGoverning() {
return this.governing;
}
public String getDisposition() {
return this.disposition;
}
public AppUser getReviewedBy() {
return this.reviewedBy;
}
public LocalDateTime getReviewedOn() {
return this.reviewedOn;
}
public String getReviewNotes() {
return this.reviewNotes;
}
}
@@ -0,0 +1,31 @@
/**
* 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.aml.domain;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
List<AmlAlertRule> findByAlertId(Long alertId);
/** Every finding across a page of alerts, in one read rather than one query per alert. */
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
}
@@ -0,0 +1,106 @@
/**
* 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.aml.domain;
/**
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
*
* <p>
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
* fortieth review cost an officer the same effort as the first and changed as little.
*
* <p>
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
* about should come back sooner, not later.
*/
public enum AmlRecurringEscalationTier {
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
ROUTINE(0, 90),
/**
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
* the reviewer is asked to consider whether the rating still fits.
*/
ELEVATED(7, 30),
/**
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
* to be taken by someone senior.
*/
SENIOR(15, 14),
/**
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
* is a decision that has to be defensible on its own - so the review stands for a week only.
*/
MANDATORY_STR(30, 7);
private final int minimumAlertCount;
private final int reviewValidityDays;
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
this.minimumAlertCount = minimumAlertCount;
this.reviewValidityDays = reviewValidityDays;
}
/** The tier a customer sitting on this many alerts falls into. */
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
AmlRecurringEscalationTier tier = ROUTINE;
for (final AmlRecurringEscalationTier candidate : values()) {
if (alertCount >= candidate.minimumAlertCount) {
tier = candidate;
}
}
return tier;
}
public int getMinimumAlertCount() {
return minimumAlertCount;
}
public int getReviewValidityDays() {
return reviewValidityDays;
}
/**
* How far the alert count may climb before a standing review is set aside and the customer returns early.
*
* <p>
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
*/
public int alertGrowthBeforeEarlyReturn() {
return switch (this) {
case ROUTINE -> 5;
case ELEVATED -> 4;
case SENIOR -> 3;
case MANDATORY_STR -> 2;
};
}
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
public boolean expectsStr() {
return this == MANDATORY_STR;
}
}
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
/**
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
* behaviour of lapsing the moment one more alert lands.
*/
@Column(name = "valid_until")
private LocalDate validUntil;
@Column(name = "escalation_tier", length = 20)
private String escalationTier;
/** How many of the customer's open alerts this review actually disposed of. */
@Column(name = "alerts_actioned")
private Integer alertsActioned;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
// always being cleared.
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
review.escalationTier = tier.name();
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
review.alertsActioned = 0;
return review;
}
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
public void recordAlertsActioned(final int count) {
this.alertsActioned = count;
}
public Long getId() {
return id;
}
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public LocalDate getValidUntil() {
return validUntil;
}
public AmlRecurringEscalationTier getEscalationTier() {
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
}
public Integer getAlertsActioned() {
return alertsActioned;
}
/**
* Whether this review still holds the customer out of the review queue.
*
* <p>
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
* lapsing as soon as one more alert arrives.
*/
public boolean stillStands(final int currentAlertCount) {
if (validUntil == null) {
return currentAlertCount <= alertCountAtReview;
}
if (LocalDate.now().isAfter(validUntil)) {
return false;
}
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
}
}
@@ -0,0 +1,83 @@
/**
* 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.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
/**
* One alert covered by an STR case.
*
* <p>
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
* Principal Officer has to be able to read in one place.
*/
@Entity
@Table(name = "m_aml_str_case_alert")
public class AmlStrCaseAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "str_case_id", nullable = false)
private AmlStrCase strCase;
@ManyToOne(optional = false)
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "added_on_utc", nullable = false)
private LocalDateTime addedOn;
protected AmlStrCaseAlert() {}
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
final AmlStrCaseAlert link = new AmlStrCaseAlert();
link.strCase = strCase;
link.alert = alert;
link.addedOn = LocalDateTime.now();
return link;
}
public Long getId() {
return id;
}
public AmlStrCase getStrCase() {
return strCase;
}
public AmlAlert getAlert() {
return alert;
}
public LocalDateTime getAddedOn() {
return addedOn;
}
}
@@ -0,0 +1,40 @@
/**
* 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.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
/** The alerts one case covers, oldest first - the order the pattern happened in. */
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
/**
* The case already covering an alert, if any.
*
* <p>
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
* one, however it comes to be escalated again.
*/
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
long countByStrCase_Id(Long strCaseId);
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
/** The case already open for a transaction, so escalating it again does not open a second one. */
Optional<AmlStrCase> findByAlertId(Long alertId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
/**
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
*/
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
}
@@ -0,0 +1,219 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* What one customer's money actually did over a period, and what that says about their rating.
*
* <p>
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
* Neither is a pattern.
*
* <p>
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
* the system was looking at rather than a conclusion with no working.
*/
@Entity
@Table(name = "m_client_behaviour_window")
public class ClientBehaviourWindow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "window_from", nullable = false)
private LocalDate windowFrom;
@Column(name = "window_to", nullable = false)
private LocalDate windowTo;
@Column(name = "txn_count")
private Integer txnCount;
@Column(name = "distinct_months")
private Integer distinctMonths;
@Column(name = "credit_total", precision = 19, scale = 6)
private BigDecimal creditTotal;
@Column(name = "debit_total", precision = 19, scale = 6)
private BigDecimal debitTotal;
@Column(name = "largest_txn", precision = 19, scale = 6)
private BigDecimal largestTxn;
@Column(name = "cash_txn_count")
private Integer cashTxnCount;
@Column(name = "cash_total", precision = 19, scale = 6)
private BigDecimal cashTotal;
@Column(name = "large_txn_count")
private Integer largeTxnCount;
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
@Column(name = "income_ratio", precision = 19, scale = 6)
private BigDecimal incomeRatio;
@Column(name = "alert_count")
private Integer alertCount;
@Column(name = "str_case_count")
private Integer strCaseCount;
@Column(name = "risk_category_at_review")
private Integer riskCategoryAtReview;
/** UP, DOWN or HOLD. */
@Column(name = "suggested_direction", length = 10)
private String suggestedDirection;
@Column(name = "suggested_category")
private Integer suggestedCategory;
@Column(name = "suggestion_reason", length = 500)
private String suggestionReason;
@Column(name = "computed_on")
private LocalDateTime computedOn;
protected ClientBehaviourWindow() {}
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
final ClientBehaviourWindow window = new ClientBehaviourWindow();
window.clientId = clientId;
window.windowFrom = windowFrom;
window.windowTo = windowTo;
window.computedOn = LocalDateTime.now();
return window;
}
public void recordDistinctMonths(final Integer months) {
this.distinctMonths = months;
}
public Integer getDistinctMonths() {
return this.distinctMonths;
}
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
this.txnCount = txnCount;
this.creditTotal = creditTotal;
this.debitTotal = debitTotal;
this.largestTxn = largestTxn;
this.cashTxnCount = cashTxnCount;
this.cashTotal = cashTotal;
this.largeTxnCount = largeTxnCount;
}
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
this.alertCount = alertCount;
this.strCaseCount = strCaseCount;
this.riskCategoryAtReview = riskCategoryAtReview;
}
/**
* Turnover measured against what the customer said to expect.
*
* <p>
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
* unknown as a benign zero would clear every customer the bank never asked.
*/
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
this.declaredAnnualIncome = income;
this.declaredAnnualTurnover = turnover;
final BigDecimal basis = income != null && income.signum() > 0 ? income
: (turnover != null && turnover.signum() > 0 ? turnover : null);
this.incomeRatio = basis == null || periodTurnover == null ? null
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
}
public void recordSuggestion(final String direction, final Integer category, final String reason) {
this.suggestedDirection = direction;
this.suggestedCategory = category;
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
}
public Long getClientId() {
return this.clientId;
}
public LocalDate getWindowFrom() {
return this.windowFrom;
}
public LocalDate getWindowTo() {
return this.windowTo;
}
public Integer getTxnCount() {
return this.txnCount;
}
public BigDecimal getCashTotal() {
return this.cashTotal;
}
public Integer getLargeTxnCount() {
return this.largeTxnCount;
}
public Integer getAlertCount() {
return this.alertCount;
}
public Integer getStrCaseCount() {
return this.strCaseCount;
}
public Integer getRiskCategoryAtReview() {
return this.riskCategoryAtReview;
}
public String getSuggestedDirection() {
return this.suggestedDirection;
}
public Integer getSuggestedCategory() {
return this.suggestedCategory;
}
}
@@ -0,0 +1,43 @@
/**
* 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.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
List<ClientBehaviourWindow> findByClientId(Long clientId);
/**
* Clears the windows computed for a given day before the job writes that day's.
*
* <p>
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
* set of windows rather than a customer appearing several times on the review screen with slightly different
* figures.
*/
@Modifying
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
}
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "form15_fy", length = 10)
private String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
/**
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
*/
@Column(name = "is_pep", nullable = false)
private boolean isPep;
@Column(name = "sanctions_match", nullable = false)
private boolean sanctionsMatch;
@Column(name = "adverse_media_match", nullable = false)
private boolean adverseMediaMatch;
/** What the customer told the bank to expect, which their actual turnover is measured against. */
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
// ── CKYCRR ───────────────────────────────────────────────────────────────
/** Central KYC Records Registry number issued by CERSAI */
@Column(name = "ckyc_number", length = 14)
@@ -527,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
// Risk profile. These are determinations about the customer rather than about a transaction, and the
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
// period's activity looks.
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
v -> this.sanctionsMatch = v != null ? v : false);
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
v -> this.adverseMediaMatch = v != null ? v : false);
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
v -> this.declaredAnnualIncome = v);
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
v -> this.declaredAnnualTurnover = v);
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -898,6 +936,26 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
recalculateExpiryForRiskCategory();
}
public boolean isPep() {
return this.isPep;
}
public boolean isSanctionsMatch() {
return this.sanctionsMatch;
}
public boolean isAdverseMediaMatch() {
return this.adverseMediaMatch;
}
public BigDecimal getDeclaredAnnualIncome() {
return this.declaredAnnualIncome;
}
public BigDecimal getDeclaredAnnualTurnover() {
return this.declaredAnnualTurnover;
}
public String getPanNumber() {
return panNumber;
}
@@ -0,0 +1,74 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
*
* <p>
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
* three times a month needs different figures from one lending to traders.
*/
@Entity
@Table(name = "m_risk_policy_parameter")
public class RiskPolicyParameter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parameter_key", nullable = false, length = 80)
private String parameterKey;
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
private BigDecimal parameterValue;
@Column(name = "description", length = 500)
private String description;
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@Column(name = "last_modified_on")
private LocalDateTime lastModifiedOn;
protected RiskPolicyParameter() {}
public String getParameterKey() {
return this.parameterKey;
}
public BigDecimal getParameterValue() {
return this.parameterValue;
}
public String getDescription() {
return this.description;
}
}
@@ -0,0 +1,23 @@
/**
* 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.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
@@ -61,6 +61,8 @@ public interface ShareAccountApiConstants {
String externalid_paramname = "externalId";
String resolutionnumber_paramname = "resolutionNumber";
/** Date of the board meeting that passed the resolution. */
String resolutiondate_paramname = "resolutionDate";
String currency_paramname = "currencyCode";
@@ -146,7 +148,7 @@ public interface ShareAccountApiConstants {
Set<String> supportedParameters = Collections.unmodifiableSet(new HashSet<>(Arrays.asList(locale_paramname, dateformat_paramname,
id_paramname, clientid_paramname, productid_paramname, submitteddate_paramname, approveddate_paramname, externalid_paramname,
resolutionnumber_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
resolutionnumber_paramname, resolutiondate_paramname, currency_paramname, digitsafterdecimal_paramname, inmultiplesof_paramname,
requestedshares_paramname, savingsaccountid_paramname, lockinperiod_paramname, lockperiodfrequencytype_paramname,
minimumactiveperiod_paramname, minimumactiveperiodfrequencytype_paramname,
allowdividendcalculationforinactiveclients_paramname, charges_paramname, applicationdate_param, purchaseddate_paramname,
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.api;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
@@ -30,4 +31,13 @@ public class AlertReviewRequest {
private String disposition;
private String notes;
/**
* The rules being decided, when the officer is deciding some of them rather than the whole alert.
*
* <p>
* Absent or empty means the decision covers the transaction and every rule it matched, which is what the row-level
* Whitelist and Suspicious buttons send and what this endpoint has always done.
*/
private List<String> ruleCodes;
}
@@ -71,7 +71,13 @@ public class AmlAlertApiResource {
description = "disposition WHITELIST closes the alert as a false positive; ESCALATE creates an STR case for Principal Officer review.")
public String review(@PathParam("alertId") final Long alertId, final AlertReviewRequest request) {
this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT");
if ("ESCALATE".equalsIgnoreCase(request.getDisposition())) {
final boolean escalating = "ESCALATE".equalsIgnoreCase(request.getDisposition());
if (request.getRuleCodes() != null && !request.getRuleCodes().isEmpty()) {
// Deciding named rules within the alert. The alert's own status follows from what is left undecided, so
// it is not set here.
this.writePlatformService.reviewRules(alertId, request.getRuleCodes(), escalating ? "ESCALATE" : "WHITELIST",
request.getNotes());
} else if (escalating) {
this.writePlatformService.escalate(alertId, request.getNotes());
} else {
this.writePlatformService.whitelist(alertId, request.getNotes());
@@ -72,6 +72,17 @@ public class AmlCtrReportApiResource {
return this.lineJsonSerializer.serialize(this.readPlatformService.retrieveLines(reportId));
}
@GET
@Path("pending-lines")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reportable cash transactions not yet in any generated CTR",
description = "The monthly batch is a snapshot, so a transaction posted after it ran belongs to that month's filing but sits in no report. These are those transactions, shaped as lines, so a reportable one is never invisible.")
public String retrievePendingLines() {
this.context.authenticatedUser();
return this.lineJsonSerializer.serialize(this.readPlatformService.retrievePendingLines());
}
@POST
@Path("generate")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,172 @@
/**
* 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.aml.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.function.Supplier;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.service.ClientRiskChangeRequestService;
import org.springframework.stereotype.Component;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* Raised from the Risk Review Report, which already works out which customers are rated below what
* their behaviour warrants. The rating moves only when a checker approves, and the change is written
* into the customer's risk history with the reason given.
*/
@Path("/v1/aml/risk-change-requests")
@Component
@Tag(name = "Client Risk Category Change",
description = "Maker-checker changes to a customer's KYC risk category, raised from the Risk Review Report.")
@RequiredArgsConstructor
public class ClientRiskChangeApiResource {
private final PlatformSecurityContext context;
private final ClientRiskChangeRequestService service;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* Business-rule failures in this module are plain IllegalArgument/IllegalState exceptions, which
* Fineract's global handler does not map - left uncaught they reach the client as an opaque 500
* with no message. Wrapped so the reviewer is told what actually stopped them.
*/
private <T> T safely(final Supplier<T> action) {
try {
return action.get();
} catch (final IllegalArgumentException | IllegalStateException e) {
throw apiError(400, e.getMessage());
}
}
private WebApplicationException apiError(final int httpStatus, final String message) {
final JsonObject body = new JsonObject();
body.addProperty("message", message);
body.addProperty("defaultUserMessage", message);
body.addProperty("httpStatus", httpStatus);
final JsonArray errors = new JsonArray();
final JsonObject err = new JsonObject();
err.addProperty("defaultUserMessage", message);
errors.add(err);
body.add("errors", errors);
return new WebApplicationException(
Response.status(httpStatus).entity(this.gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
}
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List risk category change requests",
description = "Optionally filtered by status (PENDING/APPROVED/REJECTED) for the approval queue.")
public String retrieveAll(@QueryParam("status") final String status) {
this.context.authenticatedUser();
return this.gson.toJson(this.service.retrieveAll(status));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Request a change to a customer's risk category",
description = "Maker step. Nothing moves until a checker approves.")
public CommandProcessingResult request(final RiskChangeRequest request) {
return safely(() -> this.service.request(request.getClientId(), request.getRequestedCategory(), request.getReason()));
}
@POST
@Path("{requestId}/approve")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Approve a risk category change",
description = "Checker step: applies the new category to the customer's KYC record, moves the re-KYC due date onto that category's cycle, and writes the change into the risk history.")
public CommandProcessingResult approve(@PathParam("requestId") final Long requestId) {
return safely(() -> this.service.approve(requestId));
}
@POST
@Path("{requestId}/reject")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Reject a risk category change", description = "Checker step. The customer's rating is left as it is.")
public CommandProcessingResult reject(@PathParam("requestId") final Long requestId, final RiskChangeRejectRequest request) {
return safely(() -> this.service.reject(requestId, request.getRejectionReason()));
}
/** Request bodies. Plain beans so Jackson binds them without a serializer of their own. */
public static final class RiskChangeRequest {
private Long clientId;
private Integer requestedCategory;
private String reason;
public Long getClientId() {
return clientId;
}
public void setClientId(Long clientId) {
this.clientId = clientId;
}
public Integer getRequestedCategory() {
return requestedCategory;
}
public void setRequestedCategory(Integer requestedCategory) {
this.requestedCategory = requestedCategory;
}
public String getReason() {
return reason;
}
public void setReason(String reason) {
this.reason = reason;
}
}
public static final class RiskChangeRejectRequest {
private String rejectionReason;
public String getRejectionReason() {
return rejectionReason;
}
public void setRejectionReason(String rejectionReason) {
this.rejectionReason = rejectionReason;
}
}
}
@@ -30,6 +30,16 @@ import java.time.LocalDateTime;
*/
public record AmlAlertData(Long id, String transactionType, Long transactionId, Long clientId, String clientDisplayName,
String ruleCode, String ruleDescription, BigDecimal amount, String currencyCode, LocalDateTime raisedOn, String status,
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory)
String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory,
/** Other rules the same transaction matched, comma-separated. Null when only one fired. */
String alsoMatchedRules,
/** Worst severity across every rule the transaction matched, not only the governing one. */
Integer highestSeverity,
/**
* Every rule the transaction matched, governing one first, each with its description and severity. The
* comma-separated {@code alsoMatchedRules} is kept for what already reads it; this is what the screen expands
* to show, because a bare rule code does not tell an officer what was detected or how serious it is.
*/
java.util.List<AmlMatchedRuleData> matchedRules)
implements Serializable {
}
@@ -0,0 +1,43 @@
/**
* 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.aml.data;
import java.io.Serializable;
/**
* One rule a transaction matched, as the alert screen needs to show it.
*
* <p>
* An alert names only the rule it was raised under; the rest were a comma-separated list of codes and nothing more. An
* officer deciding what to do with a transaction has to know what each of those codes means and how serious it is -
* "NEW_ACCOUNT_HIGH_VALUE_TXN" says nothing on its own, and whether it is Medium or High changes the decision.
*
* @param governing
* whether this is the rule the alert was raised under. One transaction raises one alert, under whichever
* rule most warrants attention, so exactly one entry in the list carries this.
* @param riskCategory
* 1 Low, 2 Medium, 3 High. Null for a rule with no catalog ancestor - the CTR rules are statutory returns
* rather than graded suspicions, so they carry no band.
*/
public record AmlMatchedRuleData(String ruleCode, String ruleDescription, Integer riskCategory, boolean governing,
/** WHITELIST, ESCALATE, or null while this rule is still open. */
String disposition, String reviewedByUsername, String reviewNotes,
/** True where the officer named this rule; false where it was closed alongside one they named. */
boolean directlyDecided) implements Serializable {
}
@@ -18,15 +18,25 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.data.AmlAlertData;
import org.apache.fineract.portfolio.aml.data.AmlMatchedRuleData;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -38,22 +48,127 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
private final AmlAlertRepository repository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlTransactionRuleRepository transactionRuleRepository;
@Override
public List<AmlAlertData> retrieve(String statusFilter) {
final List<AmlAlert> alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll()
: repository.findByStatusOrderByRaisedOnAsc(statusFilter);
// Nothing flagged is an ordinary state - a quiet period, or a bank that has just been set up - and every
// lookup below reads a list built from these alerts. An empty IN clause is a syntax error in Postgres, so
// without this the screen fails outright at the very moment it has nothing to report.
if (alerts.isEmpty()) {
return List.of();
}
// Resolved once for the whole page rather than per alert: many alerts share a rule, so a
// lookup inside the map step would re-read the same catalog rows repeatedly.
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts);
// Same reasoning for the also-matched rules, which are held on the alert as bare codes: one
// read for every code on the page, not one per alert.
// The findings beneath each alert - one row per rule, carrying that rule's own disposition. Read for the whole
// page at once rather than per alert.
final Map<Long, List<AmlAlertRule>> findingsByAlertId = this.alertRuleRepository
.findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream()
.collect(Collectors.groupingBy(f -> f.getAlert().getId()));
final Map<String, AmlTransactionRule> rulesByCode = rulesByCodeFor(alerts, findingsByAlertId);
final Map<Long, Integer> riskByRiskRuleId = riskCategoriesFor(alerts, rulesByCode.values());
return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed())
.map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList());
.map(alert -> mapToData(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId)).collect(Collectors.toList());
}
/** Severity of each originating catalog rule, keyed on m_client_risk_rule.id. */
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts) {
final List<Long> sourceIds = alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()).filter(id -> id != null).distinct()
.toList();
/**
* Every rule named anywhere on this page, keyed on its code.
*
* <p>
* Covers the findings as well as the also-matched lists, because a finding holds only a code and the pane needs the
* description behind it.
*/
private Map<String, AmlTransactionRule> rulesByCodeFor(final List<AmlAlert> alerts,
final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final Set<String> codes = alerts.stream().map(AmlAlert::getAlsoMatchedRules).filter(s -> s != null && !s.isBlank())
.flatMap(s -> Arrays.stream(s.split(","))).map(String::trim).filter(s -> !s.isEmpty())
.collect(Collectors.toCollection(java.util.HashSet::new));
findingsByAlertId.values().stream().flatMap(List::stream).map(AmlAlertRule::getRuleCode).forEach(codes::add);
alerts.stream().map(a -> a.getRule().getRuleCode()).forEach(codes::add);
if (codes.isEmpty()) {
return Map.of();
}
final Map<String, AmlTransactionRule> byCode = new HashMap<>();
this.transactionRuleRepository.findByRuleCodeIn(codes).forEach(rule -> byCode.put(rule.getRuleCode(), rule));
return byCode;
}
/**
* Every rule the transaction matched, governing one first, each carrying its description and severity.
*
* <p>
* A rule whose catalog ancestor is missing still appears, with a null severity rather than being dropped: the
* officer needs to see that it fired. A code with no rule behind it at all - a rule deleted since the alert was
* raised - keeps its code as the description, because the alert is a record of what happened and editing the rule
* set afterwards does not change that.
*/
private List<AmlMatchedRuleData> matchedRulesOf(final AmlAlert alert, final Map<Long, Integer> riskByRiskRuleId,
final Map<String, AmlTransactionRule> rulesByCode, final Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final List<AmlAlertRule> findings = findingsByAlertId.get(alert.getId());
if (findings != null && !findings.isEmpty()) {
// Governing rule first, then by code, so the pane does not reorder itself between reads.
return findings.stream()
.sorted(Comparator.comparing(AmlAlertRule::isGoverning).reversed().thenComparing(AmlAlertRule::getRuleCode))
.map(f -> new AmlMatchedRuleData(f.getRuleCode(), describe(f.getRuleCode(), rulesByCode), f.getRiskCategory(),
f.isGoverning(), f.getDisposition(),
f.getReviewedBy() == null ? null : f.getReviewedBy().getUsername(), f.getReviewNotes(),
f.isDirectlyDecided()))
.collect(Collectors.toList());
}
// No findings rows: an alert raised before they existed and not covered by the backfill. Rebuilt from the flat
// fields so the pane still shows what fired, with no per-rule disposition to report.
final List<AmlMatchedRuleData> matched = new ArrayList<>();
final Long governingSourceId = alert.getRule().getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(alert.getRule().getRuleCode(), alert.getRule().getDescription(),
governingSourceId == null ? null : riskByRiskRuleId.get(governingSourceId), true, null, null, null, false));
final String also = alert.getAlsoMatchedRules();
if (also == null || also.isBlank()) {
return matched;
}
for (final String raw : also.split(",")) {
final String code = raw.trim();
if (code.isEmpty()) {
continue;
}
final AmlTransactionRule rule = rulesByCode.get(code);
final Long sourceId = rule == null ? null : rule.getSourceClientRiskRuleId();
matched.add(new AmlMatchedRuleData(code, describe(code, rulesByCode),
sourceId == null ? null : riskByRiskRuleId.get(sourceId), false, null, null, null, false));
}
return matched;
}
/**
* A rule's description, or its code when the rule itself is gone.
*
* <p>
* An alert records what was detected at the time; a rule retired since then does not erase the finding, so the code
* stands in rather than the row disappearing.
*/
private String describe(final String ruleCode, final Map<String, AmlTransactionRule> rulesByCode) {
final AmlTransactionRule rule = rulesByCode.get(ruleCode);
return rule == null || rule.getDescription() == null ? ruleCode : rule.getDescription();
}
/**
* Severity of each originating catalog rule, keyed on m_client_risk_rule.id.
*
* <p>
* Covers the also-matched rules as well as the governing ones. Resolving only the governing rules left every
* also-matched rule reporting a null severity, which is exactly the thing the expanded row exists to show.
*/
private Map<Long, Integer> riskCategoriesFor(final List<AmlAlert> alerts, final Collection<AmlTransactionRule> alsoMatched) {
final List<Long> sourceIds = Stream
.concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()),
alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId))
.filter(id -> id != null).distinct().toList();
final Map<Long, Integer> byId = new HashMap<>();
if (sourceIds.isEmpty()) {
return byId;
@@ -62,13 +177,15 @@ public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformServ
return byId;
}
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId) {
private AmlAlertData mapToData(AmlAlert alert, Map<Long, Integer> riskByRiskRuleId, Map<String, AmlTransactionRule> rulesByCode,
Map<Long, List<AmlAlertRule>> findingsByAlertId) {
final Long sourceRiskRuleId = alert.getRule().getSourceClientRiskRuleId();
final Integer riskCategory = sourceRiskRuleId == null ? null : riskByRiskRuleId.get(sourceRiskRuleId);
return new AmlAlertData(alert.getId(), alert.getTransactionType().name(), alert.getTransactionId(), alert.getClient().getId(),
alert.getClient().getDisplayName(), alert.getRule().getRuleCode(), alert.getRule().getDescription(), alert.getAmount(),
alert.getCurrencyCode(), alert.getRaisedOn(), alert.getStatus().name(),
alert.getReviewedBy() == null ? null : alert.getReviewedBy().getUsername(), alert.getReviewedOn(),
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory);
alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory,
alert.getAlsoMatchedRules(), alert.getHighestSeverity(), matchedRulesOf(alert, riskByRiskRuleId, rulesByCode, findingsByAlertId));
}
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.util.List;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
@@ -27,4 +28,29 @@ public interface AmlAlertWritePlatformService {
AmlAlert whitelist(Long alertId, String notes);
AmlStrCase escalate(Long alertId, String notes);
/**
* Escalates several alerts into a single STR case.
*
* <p>
* For a case raised off a customer's repeated behaviour rather than one transaction. Escalating each alert
* separately would put one case per transaction in front of the Principal Officer, each holding a fragment of a
* pattern that only means anything taken together - and a customer reviewed again a month later would add several
* more. An STR to FIU-IND reports the activity, so the case has to hold all of it.
*
* @param alertIds
* the alerts the pattern is made of; any already covered by a case keep that case
* @return the case covering them, or null when every alert was already covered
*/
AmlStrCase escalateAsOneCase(List<Long> alertIds, String notes);
/**
* Decides individual rules within one alert, leaving the rest open.
*
* @param ruleCodes
* the rules being decided now
* @param disposition
* WHITELIST or ESCALATE, applied to each of them
*/
AmlAlert reviewRules(Long alertId, List<String> ruleCodes, String disposition, String notes);
}
@@ -19,12 +19,21 @@
package org.apache.fineract.portfolio.aml.service;
import jakarta.persistence.EntityNotFoundException;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertDisposition;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertStatus;
import org.apache.fineract.portfolio.aml.domain.AmlStrCase;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlert;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlStrCaseRepository;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +43,9 @@ import org.springframework.transaction.annotation.Transactional;
public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformService {
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final AmlStrCaseRepository strCaseRepository;
private final AmlStrCaseAlertRepository strCaseAlertRepository;
private final PlatformSecurityContext context;
private final ClientRiskEscalationService clientRiskEscalationService;
@@ -42,20 +53,157 @@ public class AmlAlertWritePlatformServiceImpl implements AmlAlertWritePlatformSe
public AmlAlert whitelist(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
alert.whitelist(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.WHITELIST, notes, true);
return alertRepository.save(alert);
}
@Override
public AmlStrCase escalate(Long alertId, String notes) {
final AmlAlert alert = loadAlert(alertId);
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
alert.escalate(context.authenticatedUser(), notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
// An STR is about the transaction, so an alert already escalated - from the row or from a rule inside it -
// keeps the case it already has rather than opening a second one against the same transaction.
if (wasEscalated) {
return strCaseRepository.findByAlertId(alertId).orElseGet(() -> openStrCase(alert));
}
return openStrCase(alert);
}
private AmlStrCase openStrCase(final AmlAlert alert) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
// Recorded in the link table as well as on alert_id, so every case describes what it covers the same way -
// a single-transaction case covering one alert, a pattern case covering several.
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
return strCase;
}
@Override
public AmlStrCase escalateAsOneCase(final List<Long> alertIds, final String notes) {
if (alertIds == null || alertIds.isEmpty()) {
return null;
}
final AppUser currentUser = context.authenticatedUser();
AmlStrCase strCase = null;
for (final Long alertId : alertIds) {
final AmlAlert alert = loadAlert(alertId);
// An alert already inside a case keeps it. Two cases holding one transaction would reach FIU-IND twice
// under two references, and the older case is the one an officer may already have worked on.
final AmlStrCaseAlert existing = strCaseAlertRepository.findByAlert_Id(alertId).orElse(null);
if (existing != null) {
if (strCase == null) {
strCase = existing.getStrCase();
}
continue;
}
alert.escalate(currentUser, notes);
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, true);
alertRepository.save(alert);
if (strCase == null) {
// Opened from the first alert that needed one, which is the oldest: the case is dated and referenced
// from where the pattern starts rather than from whichever alert happened to be escalated last.
strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
strCaseAlertRepository.save(AmlStrCaseAlert.link(strCase, alert));
}
return strCase;
}
/**
* Records a decision against individual rules within one alert.
*
* <p>
* A transaction can trip six rules where five are explained by the customer's declared business and the sixth is
* not. Deciding those separately is the point; the alert's own status is then derived from what is left - it stays
* open while any rule is undecided, because a case with an open finding is not a closed case.
*
* <p>
* Escalating any rule opens the STR case for the transaction, and only one: an STR is about the transaction, not
* about each rule that described it. Escalating a second rule on an alert already escalated adds the finding
* without opening a duplicate case.
*/
@Override
public AmlAlert reviewRules(final Long alertId, final List<String> ruleCodes, final String disposition, final String notes) {
final AmlAlert alert = loadAlert(alertId);
final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition);
final List<AmlAlertRule> findings = alertRuleRepository.findByAlertId(alertId);
final Set<String> requested = Set.copyOf(ruleCodes);
final List<AmlAlertRule> targeted = findings.stream().filter(f -> requested.contains(f.getRuleCode())).toList();
if (targeted.isEmpty()) {
throw new IllegalArgumentException("No matching rules on alert " + alertId);
}
for (final AmlAlertRule finding : targeted) {
finding.decide(decision, context.authenticatedUser(), notes, true);
alertRuleRepository.save(finding);
}
// Re-read state across every finding, including the ones just written, to decide where the alert now stands.
final boolean anyEscalated = findings.stream().anyMatch(AmlAlertRule::isEscalated);
final boolean wasEscalated = AmlAlertStatus.ESCALATED.equals(alert.getStatus());
// Suspicion carries the transaction, whatever else is still open. Waiting for every rule to be decided meant
// an officer could mark a withdrawal suspicious and watch the row sit there as RAISED with no STR case, purely
// because a second, innocuous rule had not been clicked - the one outcome that must never be delayed. One
// suspicious finding makes the transaction suspicious; the remaining rules are then recorded against a case
// that is already open.
if (anyEscalated) {
alert.escalate(context.authenticatedUser(), notes);
// The findings left open were carried into the escalation, so nothing is left dangling behind a decided
// case - but recorded as carried, not as named. The officer condemned the rule they ticked; the rest were
// closed to keep the case consistent, and the record must not claim otherwise.
decideAllFindings(alertId, AmlAlertDisposition.ESCALATE, notes, false);
alertRepository.save(alert);
if (!wasEscalated) {
final AmlStrCase strCase = strCaseRepository.save(AmlStrCase.openFor(alert));
clientRiskEscalationService.onStrCaseEscalated(strCase);
}
return alert;
}
// Clearing is the opposite: it only holds once nothing is left to look at. A transaction is not cleared while
// one of its rules is still unexamined.
//
// Statutory rules are not part of that test. A CTR carries no severity because it is not a graded suspicion -
// it is a return owed on the amount, filed whether or not the customer is above suspicion - so it is never
// offered to the officer to decide. Counting it as unexamined would leave the alert permanently unclearable,
// waiting on a judgement that is not the officer's to make.
if (!findings.stream().filter(f -> f.getRiskCategory() != null).allMatch(AmlAlertRule::isDecided)) {
return alertRepository.save(alert);
}
alert.whitelist(context.authenticatedUser(), notes);
return alertRepository.save(alert);
}
/**
* Carries a whole-alert decision down to every finding still open beneath it.
*
* <p>
* Deciding the alert decides everything in it. Without this a case closed from the row would leave its findings
* blank, and the expanded pane would show a whitelisted alert whose rules all still read as undecided. Findings
* already decided individually keep the decision the officer made about them.
*/
private void decideAllFindings(final Long alertId, final AmlAlertDisposition disposition, final String notes,
final boolean directlyDecided) {
for (final AmlAlertRule finding : alertRuleRepository.findByAlertId(alertId)) {
if (!finding.isDecided()) {
finding.decide(disposition, context.authenticatedUser(), notes, directlyDecided);
alertRuleRepository.save(finding);
}
}
}
private AmlAlert loadAlert(Long alertId) {
return alertRepository.findById(alertId)
.orElseThrow(() -> new EntityNotFoundException("AML alert " + alertId + " does not exist"));
@@ -26,11 +26,13 @@ import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLine;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportLineRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportStatus;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionType;
import org.apache.fineract.portfolio.aml.repository.AmlLoanTransactionQueryRepository;
import org.apache.fineract.portfolio.aml.repository.AmlSavingsTransactionQueryRepository;
@@ -66,8 +68,21 @@ public class AmlCtrReportGenerationService {
public AmlCtrReport generateForMonth(YearMonth month) {
final String reportMonth = month.toString();
if (ctrReportRepository.findByReportMonth(reportMonth).isPresent()) {
throw new IllegalStateException("CTR report for " + reportMonth + " has already been generated");
// A month can be rebuilt while its report is still a draft, and must not be once it has been submitted.
// Refusing outright was the safer-looking rule and the wrong one: the batch is a snapshot, so a cash
// transaction posted after it ran belonged to that month's filing and could never be added to it - the report
// was permanently short of a transaction the bank is obliged to report. Regenerating a draft replaces its
// lines with what the month now holds; a submitted report is left exactly as filed.
final Optional<AmlCtrReport> existing = ctrReportRepository.findByReportMonth(reportMonth);
if (existing.isPresent()) {
final AmlCtrReport report = existing.get();
if (AmlCtrReportStatus.SUBMITTED.equals(report.getStatus())) {
throw new IllegalStateException(
"CTR report for " + reportMonth + " has been submitted to FIU-IND and cannot be regenerated");
}
ctrReportLineRepository.deleteAll(ctrReportLineRepository.findByCtrReport_Id(report.getId()));
ctrReportRepository.delete(report);
ctrReportRepository.flush();
}
final LocalDate start = month.atDay(1);
@@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService {
* one month's report, or null for every generated month
*/
List<AmlCtrReportLineData> retrieveLines(Long reportId);
/**
* Reportable cash transactions that no generated report contains yet.
*
* <p>
* The monthly batch is a snapshot; anything posted after it runs belongs to that month's filing but sits in no
* report until the month is regenerated. These are those transactions, so a reportable one is never invisible.
*/
List<AmlCtrReportLineData> retrievePendingLines();
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
@@ -28,6 +29,7 @@ import org.apache.fineract.portfolio.aml.data.AmlCtrReportLineData;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReport;
import org.apache.fineract.portfolio.aml.domain.AmlCtrReportRepository;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -68,6 +70,56 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
LEFT JOIN m_savings_account sa ON sa.id = t.savings_account_id
""";
/**
* Cash transactions that a CTR must name but that no generated report contains yet.
*
* <p>
* The monthly batch takes a snapshot the moment it runs. A ₹15,00,000 cash deposit posted after that run belongs
* to the same month's filing and appears nowhere - not in the report, not on the FIU screen - until someone thinks
* to regenerate. A reportable transaction that is invisible is the one failure this report cannot have, so it is
* listed here as pending: shaped exactly like a generated line, with no report behind it.
*
* <p>
* Individually-reportable transactions only. A connected group is an arithmetic fact about a whole month and
* cannot be decided while the month is still running.
*/
private static final String PENDING_LINES_SQL = """
SELECT NULL::bigint AS id, NULL::bigint AS ctr_report_id,
to_char(t.transaction_date, 'YYYY-MM') AS report_month,
'SAVINGS' AS transaction_type, t.id AS transaction_id,
c.id AS client_id, c.display_name AS client_name, t.amount, false AS is_connected_group,
sa.account_no AS account_no, o.name AS branch_name, t.transaction_date,
CASE WHEN t.transaction_type_enum = 1 THEN 'Deposit'
WHEN t.transaction_type_enum = 2 THEN 'Withdrawal' END AS direction,
sa.activatedon_date AS account_opened_on,
sa.closedon_date AS account_closed_on,
CASE sa.status_enum WHEN 100 THEN 'SUBMITTED' WHEN 200 THEN 'APPROVED'
WHEN 300 THEN 'ACTIVE' WHEN 400 THEN 'WITHDRAWN' WHEN 500 THEN 'REJECTED'
WHEN 600 THEN 'CLOSED' WHEN 700 THEN 'PRE-MATURE CLOSED'
WHEN 800 THEN 'MATURED' END AS account_status,
k.ckyc_number AS ckyc_number,
COALESCE(NULLIF(c.external_id, ''), c.account_no) AS client_external_id,
COALESCE(c.legal_form_enum, 1) = 1 AS individual
FROM m_savings_account_transaction t
JOIN m_savings_account sa ON sa.id = t.savings_account_id
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE pt.is_cash_payment = true
AND t.is_reversed = false
AND t.transaction_type_enum IN (1, 2)
AND t.amount >= ?
AND NOT EXISTS (
SELECT 1 FROM m_aml_ctr_report_line el
WHERE el.transaction_type = 'SAVINGS' AND el.transaction_id = t.id)
ORDER BY t.transaction_date DESC, t.amount DESC, t.id
""";
/** The CTR line: ₹10,00,000 in a single cash transaction. Mirrors AmlCtrReportGenerationService. */
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
private final AmlCtrReportRepository repository;
private final JdbcTemplate jdbcTemplate;
@@ -88,18 +140,35 @@ public class AmlCtrReportReadPlatformServiceImpl implements AmlCtrReportReadPlat
// Largest first: the line most likely to be questioned is the one the reviewer reads first.
sql.append(" ORDER BY r.report_month DESC, l.amount DESC, l.id ");
return jdbcTemplate.query(sql.toString(), (rs, rowNum) -> new AmlCtrReportLineData(rs.getLong("id"),
rs.getLong("ctr_report_id"), rs.getString("report_month"), rs.getString("transaction_type"),
rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"), rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(),
rs.getString("direction"), rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual")), args.toArray());
return jdbcTemplate.query(sql.toString(), LINE_MAPPER, args.toArray());
}
/**
* Reportable cash transactions no generated report contains yet.
*
* <p>
* Shaped as lines so the FIU screen can show them beside the filed ones, but with a null id and report id: they
* are not in a filing, and nothing here pretends they are. The filing is still made by generating the month, which
* is what makes them disappear from this list.
*/
@Override
public List<AmlCtrReportLineData> retrievePendingLines() {
return jdbcTemplate.query(PENDING_LINES_SQL, LINE_MAPPER, CTR_THRESHOLD);
}
/** Shared by both queries, which select the same columns so a pending line renders like a filed one. */
private static final RowMapper<AmlCtrReportLineData> LINE_MAPPER = (rs, rowNum) -> new AmlCtrReportLineData(
rs.getObject("id") == null ? null : rs.getLong("id"),
rs.getObject("ctr_report_id") == null ? null : rs.getLong("ctr_report_id"), rs.getString("report_month"),
rs.getString("transaction_type"), rs.getObject("transaction_id") == null ? null : rs.getLong("transaction_id"),
rs.getLong("client_id"), rs.getString("client_name"), rs.getString("account_no"), rs.getString("branch_name"),
rs.getDate("transaction_date") == null ? null : rs.getDate("transaction_date").toLocalDate(), rs.getString("direction"),
rs.getBigDecimal("amount"), rs.getBoolean("is_connected_group"),
rs.getDate("account_opened_on") == null ? null : rs.getDate("account_opened_on").toLocalDate(),
rs.getDate("account_closed_on") == null ? null : rs.getDate("account_closed_on").toLocalDate(),
rs.getString("account_status"), rs.getString("ckyc_number"), rs.getString("client_external_id"),
rs.getBoolean("individual"));
private AmlCtrReportData mapToData(AmlCtrReport report) {
return new AmlCtrReportData(report.getId(), report.getReportMonth(), report.getGeneratedOn(), report.getRecordCount(),
report.getFileReference(), report.getStatus().name(), report.getSubmittedOn());
@@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle
final List<AmlAlertData> alerts = byClient.get(clientId);
final AmlRecurringReview lastReview = this.reviewRepository.findByClient_IdOrderByReviewedOnDesc(clientId).stream().findFirst()
.orElse(null);
// Reviewed at a lower count means new alerts have landed since — the customer has to
// come back, otherwise one early review would silence them permanently.
final boolean needsReview = lastReview == null || alerts.size() > lastReview.getAlertCountAtReview();
// A standing review holds the customer out until its window runs out, or until their behaviour has moved
// on far enough that it no longer describes them - see AmlRecurringReview#stillStands. It used to lapse on
// the very next alert, which brought a customer whose ordinary business trips a rule weekly straight back
// the day after being reviewed, for the officer to re-read the explanation they had just written.
final boolean needsReview = lastReview == null || !lastReview.stillStands(alerts.size());
if (onlyNeedingReview && !needsReview) {
continue;
}
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
@@ -26,6 +27,8 @@ import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome;
import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository;
@@ -45,6 +48,8 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
private final AmlRecurringAlertReadPlatformService readPlatformService;
private final ClientRepositoryWrapper clientRepository;
private final PlatformSecurityContext context;
private final AmlAlertRepository alertRepository;
private final AmlAlertWritePlatformService alertWritePlatformService;
@Override
@Transactional
@@ -59,9 +64,58 @@ public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAl
final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(),
this.context.authenticatedUser());
// The outcome now does what its name says. It used to do nothing at all: all three choices wrote the same row
// and the alerts were left exactly as they were, so an officer who chose ESCALATED believed they had raised an
// STR while the alerts sat untouched and no case ever reached the Principal Officer.
final int actioned = applyOutcomeToOpenAlerts(clientId, parsedOutcome, notes.trim(), window);
review.recordAlertsActioned(actioned);
return this.reviewRepository.saveAndFlush(review).getId();
}
/**
* Carries the reviewer's conclusion through to the customer's open alerts.
*
* <pre>
* CLEARED every open alert is whitelisted - the behaviour was explained, so nothing is left outstanding
* ESCALATED every open alert is escalated, which opens an STR case for the Principal Officer
* MONITOR nothing is disposed of - the point of monitoring is that the alerts stay open
* </pre>
*
* Each alert is disposed of individually rather than in bulk, because whitelist() and escalate() already carry the
* audit trail, the per-rule findings and the STR-case handling that a review has to leave behind. Reproducing any
* of that here would be a second, quieter path to the same records.
*/
private int applyOutcomeToOpenAlerts(final Long clientId, final AmlRecurringReviewOutcome outcome, final String notes,
final int windowDays) {
if (AmlRecurringReviewOutcome.MONITOR.equals(outcome)) {
return 0;
}
final LocalDateTime since = LocalDateTime.now().minusDays(windowDays);
final List<AmlAlert> openAlerts = this.alertRepository.findOpenAlertsForClientSince(clientId, since);
final String trail = "Recurring-behaviour review: " + notes;
if (AmlRecurringReviewOutcome.ESCALATED.equals(outcome)) {
// One case for the whole pattern, not one per transaction. What is being reported is the customer's
// repeated activity, and the Principal Officer investigating it needs the transactions that make it up
// together - a case per alert would hand them four fragments of one story, and five more the next time
// this customer was reviewed.
this.alertWritePlatformService.escalateAsOneCase(openAlerts.stream().map(AmlAlert::getId).toList(), trail);
return openAlerts.size();
}
int actioned = 0;
for (final AmlAlert alert : openAlerts) {
this.alertWritePlatformService.whitelist(alert.getId(), trail);
actioned++;
}
return actioned;
}
private AmlRecurringReviewOutcome validate(final Long clientId, final String outcome, final String notes) {
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE);
@@ -20,17 +20,27 @@ package org.apache.fineract.portfolio.aml.service;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.aml.data.AmlDirection;
import org.apache.fineract.portfolio.aml.data.AmlTransactionContext;
import org.apache.fineract.portfolio.aml.domain.AmlAlert;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRule;
import org.apache.fineract.portfolio.aml.domain.AmlAlertRuleRepository;
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepository;
import org.apache.fineract.portfolio.client.domain.ClientRiskRule;
import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
@@ -51,9 +61,39 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
private static final int MEDIUM_RISK = 2;
private static final int HIGH_RISK = 3;
/**
* Rules that carry a filing obligation in their own right, whatever else the transaction also
* matched.
*
* <p>
* A CTR is not a suspicion - it is a return the bank owes FIU-IND on any cash transaction at or
* above ten lakh, and it is filed whether or not the customer is above suspicion. Letting a
* behavioural rule outrank it would bury a statutory filing behind an internal observation.
*/
private static final Set<String> STATUTORY_RULE_CODES = Set.of("CTR_THRESHOLD_10L", "LARGE_CASH_TXN_25L",
"STRUCTURED_CTR_10L_MONTHLY");
/**
* Which rule an alert is raised under when a transaction matches several.
*
* <p>
* Statutory reporting first, because those must be filed. Then the largest threshold, which is
* the system's own measure of how far out of the ordinary the transaction is - a rule written
* for ₹25,00,000 is describing something graver than one written for ₹50,000. Rule code last,
* so the same transaction always reports under the same rule rather than shifting with whatever
* order the database returned.
*/
private static final Comparator<AmlTransactionRule> RULE_PRECEDENCE = Comparator
.comparing((AmlTransactionRule r) -> STATUTORY_RULE_CODES.contains(r.getRuleCode()) ? 0 : 1)
.thenComparing(r -> r.getThresholdAmount() == null ? BigDecimal.valueOf(-1) : r.getThresholdAmount(),
Comparator.reverseOrder())
.thenComparing(AmlTransactionRule::getRuleCode);
private final AmlTransactionRuleRepository ruleRepository;
private final AmlAlertRepository alertRepository;
private final AmlAlertRuleRepository alertRuleRepository;
private final ClientKycRepository clientKycRepository;
private final ClientRiskRuleRepository clientRiskRuleRepository;
private final AmlRecurringAlertNotificationService recurringAlertNotificationService;
private final AmlSubjectScopeResolver subjectScopeResolver;
@@ -63,15 +103,55 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(context.getTransactionType().name()));
candidates.addAll(ruleRepository.findByActiveTrueAndAppliesTo(AmlAppliesTo.ALL.name()));
final List<AmlAlert> raised = new ArrayList<>();
final List<AmlTransactionRule> matched = new ArrayList<>();
for (final AmlTransactionRule rule : candidates) {
if (isHit(rule, context)) {
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(), rule,
context.getAmount(), context.getCurrencyCode(), context.getOccurredOn());
raised.add(alertRepository.save(alert));
matched.add(rule);
}
}
final List<AmlAlert> raised = new ArrayList<>();
if (!matched.isEmpty()) {
// One transaction, one alert. Raising one per matched rule meant a single ₹12,00,000
// cash deposit arrived as two open alerts - the CTR and the new-account rule - and the
// officer had to dispose of each. Nothing stopped them whitelisting one while
// escalating the other, leaving the same deposit recorded as both cleared and
// suspicious. A monitoring system reports a transaction once, under the rule that most
// warrants attention.
matched.sort(RULE_PRECEDENCE);
final AmlTransactionRule governing = matched.get(0);
// When the alert was raised, not the transaction's value date. The context carries the date the
// transaction is booked to, which is a LocalDate widened to midnight - so every alert on the screen read
// "00:00", and two alerts minutes apart were indistinguishable. A monitoring feed is ordered by when it
// noticed something; the value date is still on the transaction the alert points at.
final AmlAlert alert = AmlAlert.raise(context.getTransactionType(), context.getTransactionId(), context.getClient(),
governing, context.getAmount(), context.getCurrencyCode(), DateUtils.getLocalDateTimeOfTenant());
// The rules that did not govern are still recorded: an analyst needs to see that the
// deposit was both reportable and on a new account, even though it is one case.
if (matched.size() > 1) {
alert.recordAlsoMatchedRules(
matched.stream().skip(1).map(AmlTransactionRule::getRuleCode).collect(Collectors.joining(", ")));
}
// The worst band across everything that matched, not only the rule the alert was raised
// under. A statutory CTR outranks a high-severity behavioural rule when the governing
// rule is picked, so without this the screen would see "CTR, no severity" and offer the
// transaction up for a batch decision alongside ordinary low-value alerts.
final Map<Long, Integer> bandBySourceId = severityBySourceRuleId(matched);
alert.recordHighestSeverity(highestSeverityOf(bandBySourceId));
final AmlAlert saved = alertRepository.save(alert);
// One finding per rule, so each can be disposed of on its own. Severity is copied in
// rather than read back through the rule later: the alert records what was detected at
// the time, and re-banding a rule afterwards must not rewrite that.
for (final AmlTransactionRule rule : matched) {
final Long sourceId = rule.getSourceClientRiskRuleId();
alertRuleRepository.save(AmlAlertRule.of(saved, rule.getRuleCode(),
sourceId == null ? null : bandBySourceId.get(sourceId), rule == governing));
}
raised.add(saved);
}
// Every transaction type funnels through here, so the recurring-behaviour check is done once
// in one place rather than repeated in each of the savings / loan / transfer callers. Passing
// the batch size lets the service tell a first-time offender from a repeat one without
@@ -82,6 +162,45 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService {
return raised;
}
/**
* The worst severity band among the rules that matched.
*
* <p>
* A transaction rule carries no severity of its own; it inherits the risk category of the
* catalog entry it was derived from. Rules with no catalog ancestor - the CTR rules - contribute
* nothing, because a statutory return is not a graded suspicion. Null when none of the matched
* rules carried a severity at all.
*/
private Integer highestSeverityOf(final Map<Long, Integer> bandBySourceId) {
Integer worst = null;
for (final Integer band : bandBySourceId.values()) {
if (band != null && (worst == null || band > worst)) {
worst = band;
}
}
return worst;
}
/**
* Severity of each matched rule's catalog ancestor, keyed on {@code m_client_risk_rule.id}.
*
* <p>
* Read once and used twice - for the alert's own highest-severity figure and for the per-rule findings written
* beneath it - rather than querying the catalog twice for the same rows.
*/
private Map<Long, Integer> severityBySourceRuleId(final List<AmlTransactionRule> matched) {
final List<Long> sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null)
.distinct().toList();
if (sourceIds.isEmpty()) {
return Map.of();
}
final Map<Long, Integer> bandById = new HashMap<>();
for (final ClientRiskRule catalogRule : clientRiskRuleRepository.findAllById(sourceIds)) {
bandById.put(catalogRule.getId(), catalogRule.getRiskCategory());
}
return bandById;
}
private boolean isHit(AmlTransactionRule rule, AmlTransactionContext context) {
if (!appliesToThisTransaction(rule, context)) {
return false;
@@ -0,0 +1,46 @@
/**
* 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.aml.service;
import java.util.List;
import java.util.Map;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
/**
* Moving a customer's KYC risk category, under maker-checker.
*
* <p>
* The category decides how often the customer is re-KYC'd and what AML thresholds apply to them, so
* raising or lowering one is a decision about how closely the bank watches that customer. It is
* raised from the Risk Review Report - which already works out where a rating ought to sit - and
* takes effect only when a checker agrees.
*/
public interface ClientRiskChangeRequestService {
/** Raised by the reviewer. Nothing moves until a checker approves. */
CommandProcessingResult request(Long clientId, Integer requestedCategory, String reason);
/** Applies the new category to the customer's KYC record and records the change in history. */
CommandProcessingResult approve(Long requestId);
CommandProcessingResult reject(Long requestId, String rejectionReason);
/** @param status PENDING / APPROVED / REJECTED, or null for all */
List<Map<String, Object>> retrieveAll(String status);
}
@@ -0,0 +1,241 @@
/**
* 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.aml.service;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistory;
import org.apache.fineract.portfolio.aml.domain.ClientRiskCategoryHistoryRepository;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.client.domain.ClientKyc;
import org.apache.fineract.portfolio.client.domain.ClientKycRepositoryWrapper;
import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class ClientRiskChangeRequestServiceImpl implements ClientRiskChangeRequestService {
private static final String STATUS_PENDING = "PENDING";
private static final String STATUS_APPROVED = "APPROVED";
private static final String STATUS_REJECTED = "REJECTED";
/** KycRiskCategory: 1 Low, 2 Medium, 3 High. */
private static final int LOWEST_CATEGORY = 1;
private static final int HIGHEST_CATEGORY = 3;
private final JdbcTemplate jdbcTemplate;
private final PlatformSecurityContext context;
private final ClientRepositoryWrapper clientRepository;
private final ClientKycRepositoryWrapper kycRepositoryWrapper;
private final ClientRiskCategoryHistoryRepository historyRepository;
@Override
@Transactional
public CommandProcessingResult request(final Long clientId, final Integer requestedCategory, final String reason) {
final AppUser currentUser = this.context.authenticatedUser();
if (requestedCategory == null || requestedCategory < LOWEST_CATEGORY || requestedCategory > HIGHEST_CATEGORY) {
throw new IllegalArgumentException("Requested risk category must be Low (1), Medium (2) or High (3).");
}
if (reason == null || reason.isBlank()) {
throw new IllegalArgumentException("A reason is required to change a customer's risk category.");
}
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
final Integer currentCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(currentCategory)) {
throw new IllegalStateException("The customer is already on that risk category.");
}
if (!this.jdbcTemplate
.queryForList("SELECT id FROM m_client_risk_change_request WHERE client_id = ? AND status = 'PENDING'", clientId)
.isEmpty()) {
throw new IllegalStateException(
"A risk category change is already pending for this customer. Wait for it to be approved or rejected.");
}
final KeyHolder kh = new GeneratedKeyHolder();
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(con -> {
final java.sql.PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_client_risk_change_request "
+ "(client_id, current_category, requested_category, reason, status, maker_id, created_date) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?)",
new String[] { "id" });
ps.setLong(1, client.getId());
if (currentCategory == null) {
ps.setNull(2, java.sql.Types.INTEGER);
} else {
ps.setInt(2, currentCategory);
}
ps.setInt(3, requestedCategory);
ps.setString(4, reason);
ps.setString(5, STATUS_PENDING);
ps.setLong(6, currentUser.getId());
ps.setObject(7, now);
return ps;
}, kh);
return new CommandProcessingResultBuilder().withClientId(client.getId())
.withEntityId(Objects.requireNonNull(kh.getKey()).longValue()).build();
}
@Override
@Transactional
public CommandProcessingResult approve(final Long requestId) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot approve your own risk category change.");
}
final Long clientId = ((Number) request.get("client_id")).longValue();
final Integer requestedCategory = ((Number) request.get("requested_category")).intValue();
final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId);
final ClientKyc kyc = this.kycRepositoryWrapper.findByClientIdWithNotFoundDetection(clientId);
// Read again rather than trusted from the request: the rating may have moved since - an AML
// auto-escalation on the customer's third STR case does exactly that - and the history has
// to record where it actually moved from.
final Integer previousCategory = kyc.getRiskCategoryValue();
if (requestedCategory.equals(previousCategory)) {
throw new IllegalStateException(
"The customer is already on the requested risk category; the rating moved after this request was raised.");
}
// Moves the re-KYC due date onto the new category's cycle as well: a customer rated higher
// is called back in sooner, which is most of what the rating is for.
kyc.escalateRiskCategory(requestedCategory);
this.kycRepositoryWrapper.saveAndFlush(kyc);
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.historyRepository.save(ClientRiskCategoryHistory.manual(client, previousCategory, requestedCategory,
(String) request.get("reason"), currentUser, now));
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ? WHERE id = ?",
STATUS_APPROVED, currentUser.getId(), now, requestId);
return new CommandProcessingResultBuilder().withClientId(clientId).withEntityId(requestId).build();
}
@Override
@Transactional
public CommandProcessingResult reject(final Long requestId, final String rejectionReason) {
final AppUser currentUser = this.context.authenticatedUser();
validateCheckerRole(currentUser);
final Map<String, Object> request = findRequestOrThrow(requestId);
if (!STATUS_PENDING.equals(request.get("status"))) {
throw new IllegalStateException("Risk change request is not pending. Current status: " + request.get("status"));
}
final Long makerId = ((Number) request.get("maker_id")).longValue();
if (makerId.equals(currentUser.getId()) && !currentUser.hasAnyPermission("ALL_FUNCTIONS")) {
throw new IllegalStateException("You cannot reject your own risk category change.");
}
final LocalDateTime now = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
this.jdbcTemplate.update(
"UPDATE m_client_risk_change_request SET status = ?, checker_id = ?, checker_date = ?, rejection_reason = ? "
+ "WHERE id = ?",
STATUS_REJECTED, currentUser.getId(), now, rejectionReason, requestId);
return new CommandProcessingResultBuilder().withEntityId(requestId).build();
}
@Override
public List<Map<String, Object>> retrieveAll(final String status) {
final StringBuilder sql = new StringBuilder("""
SELECT r.id AS "id",
r.client_id AS "clientId",
c.display_name AS "clientName",
c.account_no AS "cifNumber",
o.name AS "officeName",
r.current_category AS "currentCategory",
r.requested_category AS "requestedCategory",
r.reason AS "reason",
r.status AS "status",
maker.username AS "makerUsername",
to_char(r.created_date, 'YYYY-MM-DD HH24:MI') AS "createdDate",
checker.username AS "checkerUsername",
to_char(r.checker_date, 'YYYY-MM-DD HH24:MI') AS "checkerDate",
r.rejection_reason AS "rejectionReason"
FROM m_client_risk_change_request r
JOIN m_client c ON c.id = r.client_id
LEFT JOIN m_office o ON o.id = c.office_id
JOIN m_appuser maker ON maker.id = r.maker_id
LEFT JOIN m_appuser checker ON checker.id = r.checker_id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null && !status.isBlank()) {
sql.append(" AND r.status = ? ");
args.add(status);
}
sql.append(" ORDER BY r.id DESC ");
return this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
}
private Map<String, Object> findRequestOrThrow(final Long requestId) {
final List<Map<String, Object>> rows = this.jdbcTemplate
.queryForList("SELECT * FROM m_client_risk_change_request WHERE id = ?", requestId);
if (rows.isEmpty()) {
throw new IllegalArgumentException("Risk category change request not found.");
}
return rows.get(0);
}
/**
* The same checker rule the rest of the AML module uses: a rating change is a compliance
* decision, so it is approved by a manager rather than by whoever happens to be at the screen.
*/
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
return;
}
final boolean isChecker = user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("principal officer") || n.contains("compliance"));
if (!isChecker) {
throw new IllegalStateException(
"Only a Bank Manager, Compliance Officer or Principal Officer can approve or reject a risk category change.");
}
}
}
@@ -0,0 +1,56 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.serialization.ToApiJsonSerializer;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/risk-review/behaviour-windows")
@Component
@Tag(name = "Client Behaviour Windows", description = "What each customer's money did over the review period, and the rating that suggests.")
@RequiredArgsConstructor
public class ClientBehaviourWindowApiResource {
private final PlatformSecurityContext context;
private final ClientBehaviourWindowReadPlatformService readPlatformService;
private final ToApiJsonSerializer<ClientBehaviourWindowData> toApiJsonSerializer;
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "The latest behaviour window for each customer",
description = "One row per customer, carrying the period's figures and the rating the policy suggests from them. Computed nightly; the screen shows what the suggestion was drawn from rather than recomputing it.")
public String retrieveAll() {
this.context.authenticatedUser();
final List<ClientBehaviourWindowData> windows = this.readPlatformService.retrieveLatestPerClient();
return this.toApiJsonSerializer.serialize(windows);
}
}
@@ -0,0 +1,39 @@
/**
* 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.data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* One customer's review period as the Risk Review screen shows it.
*
* <p>
* The figures travel with the suggestion rather than being recomputed by the screen. A recommendation an officer is
* asked to approve has to show what it was drawn from - months later, the same row must still explain itself - and a
* second calculation in the browser could disagree with the one the policy actually used.
*/
public record ClientBehaviourWindowData(Long clientId, String clientName, String accountNo, String officeName,
LocalDate windowFrom, LocalDate windowTo, Integer txnCount, Integer distinctMonths, BigDecimal creditTotal,
BigDecimal debitTotal, BigDecimal largestTxn, Integer cashTxnCount, BigDecimal cashTotal, Integer largeTxnCount,
BigDecimal declaredAnnualIncome, BigDecimal incomeRatio, Integer alertCount, Integer strCaseCount,
Integer riskCategoryAtReview, String suggestedDirection, Integer suggestedCategory, String suggestionReason)
implements Serializable {
}
@@ -97,6 +97,20 @@ public final class ClientKycApiConstants {
public static final String form15ApplicableParam = "form15Applicable";
public static final String form15TypeParam = "form15Type";
public static final String form15FYParam = "form15FY";
/**
* Customer facts the risk re-rating policy tests against.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary - that is precisely the
* risk - and the same holds for a sanctions or adverse-media match. The declared figures are what "turnover out of
* keeping with the customer's stated income" is measured against.
*/
public static final String isPepParam = "isPep";
public static final String sanctionsMatchParam = "sanctionsMatch";
public static final String adverseMediaMatchParam = "adverseMediaMatch";
public static final String declaredAnnualIncomeParam = "declaredAnnualIncome";
public static final String declaredAnnualTurnoverParam = "declaredAnnualTurnover";
public static final String isExceptionParam = "isException";
public static final String exceptionReasonParam = "exceptionReason";
public static final String exceptionResolvedParam = "exceptionResolved";
@@ -117,7 +131,9 @@ public final class ClientKycApiConstants {
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
public static final Set<String> UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam,
@@ -133,7 +149,9 @@ public final class ClientKycApiConstants {
panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam,
panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam,
aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam,
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam));
aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam,
isPepParam, sanctionsMatchParam, adverseMediaMatchParam,
declaredAnnualIncomeParam, declaredAnnualTurnoverParam));
/** verify command: existing flags + Aadhaar OTP fields for the verify-otp step. */
public static final Set<String> VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList(
@@ -122,6 +122,13 @@ public final class ClientKycData {
private final String form15Type;
private final String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
private final Boolean isPep;
private final Boolean sanctionsMatch;
private final Boolean adverseMediaMatch;
private final java.math.BigDecimal declaredAnnualIncome;
private final java.math.BigDecimal declaredAnnualTurnover;
// ── CKYCRR ────────────────────────────────────────────────────────────────
private final String ckycNumber;
private final LocalDate ckycUploadedOn;
@@ -0,0 +1,60 @@
/**
* 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.jobs.clientbehaviourwindow;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ClientBehaviourWindowJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private ClientBehaviourWindowService clientBehaviourWindowService;
@Bean
public ClientBehaviourWindowTasklet clientBehaviourWindowTasklet() {
return new ClientBehaviourWindowTasklet(clientBehaviourWindowService);
}
@Bean
protected Step clientBehaviourWindowStep() {
return new StepBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository)
.tasklet(clientBehaviourWindowTasklet(), transactionManager).build();
}
@Bean
public Job clientBehaviourWindowJob() {
return new JobBuilder(JobName.CLIENT_BEHAVIOUR_WINDOW.name(), jobRepository).start(clientBehaviourWindowStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -0,0 +1,45 @@
/**
* 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.jobs.clientbehaviourwindow;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.service.ClientBehaviourWindowService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Recomputes every customer's behaviour window and the rating each one suggests.
*
* <p>
* The job only ever writes a suggestion. Moving a rating remains a decision an officer makes and a checker approves,
* which is what a re-rating is: a judgement about a customer, recorded by someone who is accountable for it.
*/
@RequiredArgsConstructor
public class ClientBehaviourWindowTasklet implements Tasklet {
private final ClientBehaviourWindowService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.computeForAllClients();
return RepeatStatus.FINISHED;
}
}
@@ -0,0 +1,80 @@
/**
* 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 lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.data.ClientBehaviourWindowData;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Serves the most recent behaviour window for each customer, with the customer resolved.
*
* <p>
* Only the latest window per customer: the table keeps each night's computation, and a review screen showing a
* customer once per night would be unreadable within a week.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class ClientBehaviourWindowReadPlatformService {
private static final String SQL = """
SELECT w.client_id, c.display_name AS client_name, c.account_no, o.name AS office_name,
w.window_from, w.window_to, w.txn_count, w.distinct_months, w.credit_total, w.debit_total, w.largest_txn,
w.cash_txn_count, w.cash_total, w.large_txn_count, w.declared_annual_income, w.income_ratio,
w.alert_count, w.str_case_count, w.risk_category_at_review,
w.suggested_direction, w.suggested_category, w.suggestion_reason
FROM m_client_behaviour_window w
JOIN m_client c ON c.id = w.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
-- The latest window per customer. DISTINCT ON keeps the first row of each client group, and the ORDER BY
-- is what makes that the newest one.
WHERE w.id IN (SELECT DISTINCT ON (client_id) id FROM m_client_behaviour_window
ORDER BY client_id, window_to DESC, computed_on DESC, id DESC)
-- A window describes the rating the customer held when the job ran. Once an officer moves that rating
-- the window is describing a customer who no longer exists, and serving it would have the screen go on
-- asking for a change that has already been applied and approved. Suppressed until tonight's run
-- recomputes it against the rating now on file.
AND (w.risk_category_at_review IS NULL OR k.risk_category IS NULL
OR w.risk_category_at_review = k.risk_category)
ORDER BY CASE w.suggested_direction WHEN 'UP' THEN 0 WHEN 'DOWN' THEN 1 ELSE 2 END,
c.display_name
""";
private final JdbcTemplate jdbcTemplate;
public List<ClientBehaviourWindowData> retrieveLatestPerClient() {
return jdbcTemplate.query(SQL, (rs, rowNum) -> new ClientBehaviourWindowData(rs.getLong("client_id"),
rs.getString("client_name"), rs.getString("account_no"), rs.getString("office_name"),
rs.getDate("window_from") == null ? null : rs.getDate("window_from").toLocalDate(),
rs.getDate("window_to") == null ? null : rs.getDate("window_to").toLocalDate(),
(Integer) rs.getObject("txn_count"), (Integer) rs.getObject("distinct_months"), rs.getBigDecimal("credit_total"),
rs.getBigDecimal("debit_total"),
rs.getBigDecimal("largest_txn"), (Integer) rs.getObject("cash_txn_count"), rs.getBigDecimal("cash_total"),
(Integer) rs.getObject("large_txn_count"), rs.getBigDecimal("declared_annual_income"),
rs.getBigDecimal("income_ratio"), (Integer) rs.getObject("alert_count"),
(Integer) rs.getObject("str_case_count"), (Integer) rs.getObject("risk_category_at_review"),
rs.getString("suggested_direction"), (Integer) rs.getObject("suggested_category"),
rs.getString("suggestion_reason")));
}
}
@@ -0,0 +1,247 @@
/**
* 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.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindow;
import org.apache.fineract.portfolio.client.domain.ClientBehaviourWindowRepository;
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.BehaviourFacts;
import org.apache.fineract.portfolio.client.service.RiskMovementPolicyService.Recommendation;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Computes what each customer's money did over a period, and asks the policy what that means for their rating.
*
* <p>
* Run nightly. The figures are aggregated in the database rather than by loading each customer's transactions into
* memory: a book of any size would otherwise read every transaction row it has ever written, once per customer, every
* night.
*
* <p>
* Two windows are computed per customer, not one. An upgrade asks what the customer is doing now and reads a short
* window; a downgrade asks whether a year of conduct supports removing a rating. Computing only the longer window
* would delay every upgrade by months, and only the shorter would lower ratings on a quiet fortnight.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class ClientBehaviourWindowService {
/**
* One customer's aggregated activity for one window.
*
* <p>
* Every count and total is computed in SQL; nothing here re-reads transactions.
*/
private record Aggregate(Long clientId, Integer currentCategory, int txnCount, int distinctMonths, BigDecimal creditTotal,
BigDecimal debitTotal, BigDecimal largestTxn, int cashTxnCount, BigDecimal cashTotal, int largeTxnCount,
int ctrTxnCount, int mediumAlertCount, int highAlertCount, int openStrCount, int strInBlockWindow, boolean pep,
boolean sanctionsMatch, boolean adverseMedia, boolean graveRuleFired, boolean kycOverdue,
Integer daysInCurrentCategory, BigDecimal declaredIncome, BigDecimal declaredTurnover) {}
/**
* Everything the policy tests, for every active customer, in one pass.
*
* <p>
* Each subquery is scoped to the same window so the figures describe one period. Alerts are counted by their worst
* matched severity rather than by the rule the alert was raised under, because a statutory CTR outranks a
* behavioural rule when the governing rule is picked - counting the governing rule would lose the high-severity
* finding underneath it.
*/
private static final String AGGREGATE_SQL = """
SELECT c.id AS client_id,
k.risk_category AS current_category,
COALESCE(t.txn_count, 0) AS txn_count,
COALESCE(t.distinct_months, 0) AS distinct_months,
COALESCE(t.credit_total, 0) AS credit_total,
COALESCE(t.debit_total, 0) AS debit_total,
COALESCE(t.largest_txn, 0) AS largest_txn,
COALESCE(t.cash_txn_count, 0) AS cash_txn_count,
COALESCE(t.cash_total, 0) AS cash_total,
COALESCE(t.large_txn_count, 0) AS large_txn_count,
COALESCE(t.ctr_txn_count, 0) AS ctr_txn_count,
COALESCE(a.medium_alerts, 0) AS medium_alerts,
COALESCE(a.high_alerts, 0) AS high_alerts,
COALESCE(a.grave_rule, false) AS grave_rule,
COALESCE(s.open_str, 0) AS open_str,
COALESCE(s.str_in_block_window, 0) AS str_in_block_window,
COALESCE(k.is_pep, false) AS pep,
COALESCE(k.sanctions_match, false) AS sanctions_match,
COALESCE(k.adverse_media_match, false) AS adverse_media,
CASE WHEN k.expiry_date IS NOT NULL AND k.expiry_date < ? THEN true ELSE false END AS kyc_overdue,
h.days_in_category,
k.declared_annual_income,
k.declared_annual_turnover
FROM m_client c
LEFT JOIN m_client_kyc k ON k.client_id = c.id AND COALESCE(k.is_deleted, false) = false
-- Savings activity in the window, by client.
LEFT JOIN (
SELECT sa.client_id,
count(*) AS txn_count,
count(DISTINCT to_char(tx.transaction_date, 'YYYY-MM')) AS distinct_months,
sum(CASE WHEN tx.transaction_type_enum = 1 THEN tx.amount ELSE 0 END) AS credit_total,
sum(CASE WHEN tx.transaction_type_enum = 2 THEN tx.amount ELSE 0 END) AS debit_total,
max(tx.amount) AS largest_txn,
count(*) FILTER (WHERE pt.is_cash_payment) AS cash_txn_count,
COALESCE(sum(tx.amount) FILTER (WHERE pt.is_cash_payment), 0) AS cash_total,
count(*) FILTER (WHERE tx.amount >= ?) AS large_txn_count,
count(*) FILTER (WHERE pt.is_cash_payment AND tx.amount >= ?) AS ctr_txn_count
FROM m_savings_account_transaction tx
JOIN m_savings_account sa ON sa.id = tx.savings_account_id
LEFT JOIN m_payment_detail pd ON pd.id = tx.payment_detail_id
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
WHERE tx.is_reversed = false
AND tx.transaction_type_enum IN (1, 2)
AND tx.transaction_date BETWEEN ? AND ?
GROUP BY sa.client_id
) t ON t.client_id = c.id
-- Alerts in the window, graded by the worst rule each matched.
LEFT JOIN (
SELECT al.client_id,
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 2) AS medium_alerts,
count(*) FILTER (WHERE COALESCE(al.highest_severity, 0) >= 3) AS high_alerts,
bool_or(ar.rule_code IS NOT NULL) AS grave_rule
FROM m_aml_alert al
LEFT JOIN m_aml_alert_rule ar
ON ar.alert_id = al.id
AND (ar.rule_code LIKE 'STRUCTUR%' OR ar.rule_code LIKE 'PEP%'
OR ar.rule_code LIKE 'SANCTION%' OR ar.rule_code LIKE 'ADVERSE_MEDIA%'
OR ar.rule_code LIKE 'HIGH_RISK_COUNTRY%' OR ar.rule_code LIKE 'WILFUL_DEFAULTER%')
WHERE al.raised_on >= ? AND al.raised_on < ?
GROUP BY al.client_id
) a ON a.client_id = c.id
-- STR cases: open ones hold a rating at High; recent concluded ones block a downgrade.
LEFT JOIN (
SELECT sc.client_id,
count(*) FILTER (WHERE sc.status = 'OPEN') AS open_str,
count(*) FILTER (WHERE sc.created_on_utc >= ?) AS str_in_block_window
FROM m_aml_str_case sc
GROUP BY sc.client_id
) s ON s.client_id = c.id
-- How long the customer has held the rating they are on, from the last recorded change.
LEFT JOIN (
SELECT client_id, (? - max(changed_on)::date) AS days_in_category
FROM m_client_risk_category_history
GROUP BY client_id
) h ON h.client_id = c.id
WHERE c.status_enum = 300
""";
private final JdbcTemplate jdbcTemplate;
private final ClientBehaviourWindowRepository windowRepository;
private final RiskMovementPolicyService policyService;
/** ₹10,00,000 - the CTR line, and the figure "large transaction" means here. */
private static final BigDecimal CTR_THRESHOLD = new BigDecimal("1000000");
/**
* Computes tonight's windows for every active customer.
*
* <p>
* The upgrade window is written for every customer; the downgrade window only for those rated Medium or High,
* since there is nothing below Low to move to. The suggestion is taken from whichever window the policy acted on,
* so the figures shown beside a recommendation are the ones it was drawn from.
*/
@Transactional
public int computeForAllClients() {
final LocalDate today = DateUtils.getBusinessLocalDate();
windowRepository.deleteByWindowTo(today);
final int upgradeDays = policyService.windowDaysFor("UPGRADE_WINDOW_DAYS", 90);
final int highToMediumDays = policyService.windowDaysFor("HIGH_TO_MEDIUM_WINDOW_DAYS", 365);
final int mediumToLowDays = policyService.windowDaysFor("MEDIUM_TO_LOW_WINDOW_DAYS", 270);
final int strBlockMonths = policyService.windowDaysFor("STR_BLOCK_MONTHS", 24);
final List<Aggregate> shortWindow = aggregate(today.minusDays(upgradeDays), today, strBlockMonths);
int written = 0;
for (final Aggregate shortAgg : shortWindow) {
final Integer category = shortAgg.currentCategory();
// A downgrade is judged over the window that band requires; Low has nothing below it.
final int longDays = category != null && category == RiskMovementPolicyService.HIGH ? highToMediumDays : mediumToLowDays;
final Aggregate longAgg = category != null && category > RiskMovementPolicyService.LOW
? one(today.minusDays(longDays), today, strBlockMonths, shortAgg.clientId())
: null;
final Recommendation shortResult = policyService.evaluate(factsOf(shortAgg));
// The short window decides upgrades and the standing facts; anything else is a question for the long one.
final boolean actedOnShort = RiskMovementPolicyService.UP.equals(shortResult.direction()) || longAgg == null;
final Aggregate acted = actedOnShort ? shortAgg : longAgg;
final Recommendation result = actedOnShort ? shortResult : policyService.evaluate(factsOf(longAgg));
final LocalDate from = actedOnShort ? today.minusDays(upgradeDays) : today.minusDays(longDays);
final ClientBehaviourWindow window = ClientBehaviourWindow.of(acted.clientId(), from, today);
window.recordActivity(acted.txnCount(), acted.creditTotal(), acted.debitTotal(), acted.largestTxn(),
acted.cashTxnCount(), acted.cashTotal(), acted.largeTxnCount());
window.recordDistinctMonths(acted.distinctMonths());
window.recordMonitoring(acted.mediumAlertCount(), acted.openStrCount(), acted.currentCategory());
window.recordDeclared(acted.declaredIncome(), acted.declaredTurnover(),
acted.creditTotal() == null ? null : acted.creditTotal().add(acted.debitTotal()));
window.recordSuggestion(result.direction(), result.suggestedCategory(), result.reason());
windowRepository.save(window);
written++;
}
log.info("Client behaviour windows computed for {} clients as at {}", written, today);
return written;
}
private BehaviourFacts factsOf(final Aggregate a) {
final BigDecimal periodTotal = a.creditTotal().add(a.debitTotal());
return new BehaviourFacts(a.currentCategory(), a.txnCount(), a.distinctMonths(), a.cashTotal(), periodTotal,
a.largestTxn(), a.ctrTxnCount(), a.mediumAlertCount(), a.highAlertCount(), a.openStrCount(),
a.strInBlockWindow(), a.pep(), a.sanctionsMatch(), a.adverseMedia(), a.graveRuleFired(), a.kycOverdue(),
a.daysInCurrentCategory());
}
private List<Aggregate> aggregate(final LocalDate from, final LocalDate to, final int strBlockMonths) {
return query(AGGREGATE_SQL, from, to, strBlockMonths, null);
}
private Aggregate one(final LocalDate from, final LocalDate to, final int strBlockMonths, final Long clientId) {
final List<Aggregate> rows = query(AGGREGATE_SQL + " AND c.id = ? ", from, to, strBlockMonths, clientId);
return rows.isEmpty() ? null : rows.get(0);
}
private List<Aggregate> query(final String sql, final LocalDate from, final LocalDate to, final int strBlockMonths,
final Long clientId) {
final List<Object> args = new ArrayList<>(List.of(to, CTR_THRESHOLD, CTR_THRESHOLD, from, to, from.atStartOfDay(),
to.plusDays(1).atStartOfDay(), to.minusMonths(strBlockMonths).atStartOfDay(), to));
if (clientId != null) {
args.add(clientId);
}
return jdbcTemplate.query(sql, (rs, rowNum) -> new Aggregate(rs.getLong("client_id"),
rs.getObject("current_category") == null ? null : rs.getInt("current_category"), rs.getInt("txn_count"),
rs.getInt("distinct_months"), rs.getBigDecimal("credit_total"), rs.getBigDecimal("debit_total"),
rs.getBigDecimal("largest_txn"), rs.getInt("cash_txn_count"), rs.getBigDecimal("cash_total"),
rs.getInt("large_txn_count"), rs.getInt("ctr_txn_count"), rs.getInt("medium_alerts"), rs.getInt("high_alerts"),
rs.getInt("open_str"), rs.getInt("str_in_block_window"), rs.getBoolean("pep"), rs.getBoolean("sanctions_match"),
rs.getBoolean("adverse_media"), rs.getBoolean("grave_rule"), rs.getBoolean("kyc_overdue"),
rs.getObject("days_in_category") == null ? null : rs.getInt("days_in_category"),
rs.getBigDecimal("declared_annual_income"), rs.getBigDecimal("declared_annual_turnover")),
args.toArray());
}
}
@@ -132,6 +132,11 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe
.form15Applicable(kyc.getForm15Applicable())
.form15Type(kyc.getForm15Type())
.form15Fy(kyc.getForm15Fy())
.isPep(kyc.isPep())
.sanctionsMatch(kyc.isSanctionsMatch())
.adverseMediaMatch(kyc.isAdverseMediaMatch())
.declaredAnnualIncome(kyc.getDeclaredAnnualIncome())
.declaredAnnualTurnover(kyc.getDeclaredAnnualTurnover())
.ckycNumber(kyc.getCkycNumber())
.ckycUploadedOn(kyc.getCkycUploadedOn())
.ckycBatchId(kyc.getCkycBatchId())
@@ -0,0 +1,276 @@
/**
* 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.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameter;
import org.apache.fineract.portfolio.client.domain.RiskPolicyParameterRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Decides where a customer's rating should sit, given a period of their behaviour.
*
* <p>
* Two principles shape every rule here, and they are not symmetric.
*
* <p>
* <b>A rating never moves on one transaction.</b> Not up and not down. A single shop purchase does not argue for
* lowering a High rating, and a single large deposit does not make an ordinary customer suspicious - it may be a
* property sale. Both are alerts worth an officer's attention, and neither is a pattern. The minimum counts below are
* the same test the transaction rules already apply to themselves.
*
* <p>
* <b>Suspicion moves faster than confidence.</b> A concern is about what the customer is doing now, so upgrades read a
* short window. Removing a High rating is a judgement that an earlier concern no longer holds, which needs a year of
* evidence spread across months rather than a quiet fortnight. The one exception is the set of facts that are not
* about behaviour at all - an STR, a sanctions hit, a PEP determination - which move a rating immediately because no
* amount of ordinary transacting answers them.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class RiskMovementPolicyService {
public static final int LOW = 1;
public static final int MEDIUM = 2;
public static final int HIGH = 3;
public static final String UP = "UP";
public static final String DOWN = "DOWN";
public static final String HOLD = "HOLD";
private final RiskPolicyParameterRepository parameterRepository;
/** One customer's period, as the policy needs to see it. */
public record BehaviourFacts(Integer currentCategory, int txnCount, int distinctMonths, BigDecimal cashTotal,
BigDecimal periodTotal, BigDecimal largestTxn, int ctrTxnCount, int mediumAlertCount, int highAlertCount,
int openStrCount, int strCountInBlockWindow, boolean pep, boolean sanctionsMatch, boolean adverseMedia,
boolean graveRuleFired, boolean kycOverdue, Integer daysInCurrentCategory) {}
/** What the policy concluded, and the sentence an officer reads. */
public record Recommendation(String direction, Integer suggestedCategory, String reason) {}
public Recommendation evaluate(final BehaviourFacts f) {
final Map<String, BigDecimal> p = parameters();
// ── Facts that are not about behaviour ──────────────────────────────────────────────────
// These move a rating on their own. No quantity of ordinary transacting answers a sanctions match, and an STR
// is the bank's own statement that it found the customer's activity suspicious.
if (f.sanctionsMatch()) {
return high(f, "Sanctions or watch-list match — rating held at High irrespective of activity.");
}
if (f.pep()) {
return high(f, "Politically exposed person — High is the standing rating, not a conclusion about this period.");
}
if (f.adverseMedia()) {
return high(f, "Adverse media or regulatory action on record — rating held at High.");
}
if (f.openStrCount() > 0) {
return high(f, "An STR case is open — rating held at High until the Principal Officer concludes it.");
}
// ── Upgrades ────────────────────────────────────────────────────────────────────────────
// Read before the downgrade tests: a customer whose activity has grown is not a candidate for lowering, and
// saying so is more useful than "insufficient activity to lower".
final Recommendation up = considerUpgrade(f, p);
if (up != null) {
return up;
}
// ── Downgrades ──────────────────────────────────────────────────────────────────────────
return considerDowngrade(f, p);
}
private Recommendation considerUpgrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
final int current = f.currentCategory() == null ? LOW : f.currentCategory();
if (current >= HIGH) {
return null;
}
// A pattern, even to raise a rating. One large transaction is an alert for an officer to look at, not grounds
// for re-rating the customer who made it.
if (f.txnCount() < intOf(p, "MIN_TXN_FOR_UPGRADE", 5)) {
return null;
}
if (current == MEDIUM) {
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
}
if (f.graveRuleFired()) {
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
}
if (atLeast(f.cashTotal(), p.get("MED_TO_HIGH_CASH_TOTAL"))) {
return raise(HIGH, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
}
return null;
}
// Low, or unrated and treated as Low.
if (f.graveRuleFired()) {
return raise(HIGH, "Structuring or an equivalent pattern detected — not a matter of amount.");
}
if (f.highAlertCount() >= intOf(p, "MED_TO_HIGH_ALERT_COUNT", 5)) {
return raise(HIGH, "%d high-severity alerts in the review window.".formatted(f.highAlertCount()));
}
if (atLeast(f.cashTotal(), p.get("LOW_TO_MED_CASH_TOTAL"))) {
return raise(MEDIUM, "Cash turnover of %s in the review window.".formatted(inr(f.cashTotal())));
}
if (f.ctrTxnCount() >= intOf(p, "LOW_TO_MED_CTR_COUNT", 3)) {
return raise(MEDIUM, "%d CTR-reportable transactions in the review window.".formatted(f.ctrTxnCount()));
}
if (f.mediumAlertCount() >= intOf(p, "LOW_TO_MED_ALERT_COUNT", 3)) {
return raise(MEDIUM, "%d alerts of medium severity or worse in the review window.".formatted(f.mediumAlertCount()));
}
return null;
}
private Recommendation considerDowngrade(final BehaviourFacts f, final Map<String, BigDecimal> p) {
if (f.currentCategory() == null) {
// An unrated customer who is transacting is a gap in the book, not a quiet account. Saying only "no rating
// on record" leaves an officer nothing to act on, so the size of what went through is named: an unrated
// customer who has moved reportable sums needs rating now, not at the next periodic review.
final boolean reportable = f.ctrTxnCount() > 0 || atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"));
return new Recommendation(HOLD, null, reportable
? "No rating on record, and %s passed through the account in the window — the customer must be rated before this activity can be judged."
.formatted(inr(f.periodTotal()))
: "No rating on record — the customer must be rated before behaviour can be compared against it.");
}
final int current = f.currentCategory();
if (current <= LOW) {
return hold(f, "Already at the lowest rating.");
}
// Blocks. Each is a reason the period's figures cannot speak for the customer, so they are tested before the
// figures are.
if (f.strCountInBlockWindow() > 0) {
return hold(f, "An STR was filed within the last %d months — rating held.".formatted(intOf(p, "STR_BLOCK_MONTHS", 24)));
}
if (f.graveRuleFired()) {
return hold(f, "Structuring or an equivalent pattern on record — small amounts are not a reason to lower this.");
}
if (f.kycOverdue()) {
return hold(f, "KYC is overdue or documents have expired — the rating cannot be lowered on an unverified record.");
}
final boolean toMedium = current == HIGH;
final int minTxn = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_TXN" : "MEDIUM_TO_LOW_MIN_TXN", toMedium ? 20 : 12);
final int minMonths = intOf(p, toMedium ? "HIGH_TO_MEDIUM_MIN_MONTHS" : "MEDIUM_TO_LOW_MIN_MONTHS", toMedium ? 6 : 5);
final int windowDays = intOf(p, toMedium ? "HIGH_TO_MEDIUM_WINDOW_DAYS" : "MEDIUM_TO_LOW_WINDOW_DAYS", toMedium ? 365 : 270);
if (f.txnCount() < minTxn) {
return hold(f, "Only %d transactions in the window; %d are needed to lower a %s rating.".formatted(f.txnCount(), minTxn,
label(current)));
}
// Spread as well as count: twenty transactions in one week is not a year of steady conduct.
if (f.distinctMonths() < minMonths) {
return hold(f, "Activity falls in %d months; %d separate months are needed.".formatted(f.distinctMonths(), minMonths));
}
if (f.daysInCurrentCategory() != null && f.daysInCurrentCategory() < windowDays) {
return hold(f, "Only %d days at %s; %d are needed before the rating is reconsidered."
.formatted(f.daysInCurrentCategory(), label(current), windowDays));
}
if (f.highAlertCount() > 0) {
return hold(f, "%d high-severity alert(s) in the window — the period is not clean.".formatted(f.highAlertCount()));
}
// Size. Volume alone holds a rating up: a customer moving large sums is not low-risk merely for doing it
// without tripping a rule.
if (atLeast(f.periodTotal(), p.get("MATERIAL_TOTAL"))) {
return hold(f, "Turnover of %s in the window holds the rating where it is.".formatted(inr(f.periodTotal())));
}
if (!toMedium) {
// Medium to Low is the stricter of the two: Low is the least-monitored band.
if (!below(f.largestTxn(), p.get("SMALL_TXN_MAX")) || !below(f.periodTotal(), p.get("SMALL_TOTAL_MAX"))) {
return hold(f, "Activity is larger than Low-rated behaviour — largest %s, %s in total."
.formatted(inr(f.largestTxn()), inr(f.periodTotal())));
}
}
// One band at a time. High to Low in a single step would skip the year at Medium that is the evidence for it.
final int suggested = current - 1;
return new Recommendation(DOWN, suggested,
"%d transactions across %d months, %s in total, no high-severity alerts — %s is proportionate."
.formatted(f.txnCount(), f.distinctMonths(), inr(f.periodTotal()), label(suggested)));
}
private Recommendation high(final BehaviourFacts f, final String reason) {
final boolean alreadyHigh = f.currentCategory() != null && f.currentCategory() == HIGH;
return new Recommendation(alreadyHigh ? HOLD : UP, HIGH, reason);
}
private Recommendation raise(final int category, final String reason) {
return new Recommendation(UP, category, reason);
}
private Recommendation hold(final BehaviourFacts f, final String reason) {
return new Recommendation(HOLD, f.currentCategory(), reason);
}
private Map<String, BigDecimal> parameters() {
return parameterRepository.findAll().stream()
.collect(Collectors.toMap(RiskPolicyParameter::getParameterKey, RiskPolicyParameter::getParameterValue, (a, b) -> a));
}
/**
* A parameter's value, or the seeded default.
*
* <p>
* The fallback is not decoration: a tenant whose parameter table has not been seeded must still apply the policy,
* rather than reading a missing row as zero and lowering every rating that asked for "at least 0 transactions".
*/
private int intOf(final Map<String, BigDecimal> p, final String key, final int fallback) {
final BigDecimal value = p.get(key);
return value == null ? fallback : value.intValue();
}
private boolean atLeast(final BigDecimal value, final BigDecimal threshold) {
return value != null && threshold != null && value.compareTo(threshold) >= 0;
}
private boolean below(final BigDecimal value, final BigDecimal threshold) {
return value != null && threshold != null && value.compareTo(threshold) < 0;
}
private String label(final int category) {
return switch (category) {
case LOW -> "Low";
case MEDIUM -> "Medium";
case HIGH -> "High";
default -> "Unrated";
};
}
private String inr(final BigDecimal amount) {
return amount == null ? "nil" : "INR " + amount.setScale(0, java.math.RoundingMode.HALF_UP).toPlainString();
}
/** Exposed so the job can name the windows it must compute without repeating the keys. */
public int windowDaysFor(final String key, final int fallback) {
return intOf(parameters(), key, fallback);
}
public List<String> downgradeWindowKeys() {
return List.of("HIGH_TO_MEDIUM_WINDOW_DAYS", "MEDIUM_TO_LOW_WINDOW_DAYS");
}
}
@@ -0,0 +1,147 @@
/**
* 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.shareaccounts.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The Board of Resolution register for share capital: every share account with the resolution that
* authorised it.
*
* <p>
* A co-operative allots share capital only under a board resolution, and the register is what an
* auditor or the Registrar asks for - one line per member showing the resolution number against the
* shares it authorised. Served as a single query rather than assembled on the client, which had to
* fetch each account separately and stopped at two hundred of them.
*
* <p>
* Read-only; nothing here writes.
*/
@Path("/v1/shareaccounts/bor-register")
@Component
@Tag(name = "Share BOR Register",
description = "Share accounts with the board resolution that authorised them, for the BOR register.")
@RequiredArgsConstructor
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
public class ShareBorRegisterApiResource {
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new GsonBuilder().serializeNulls().create();
/**
* @param status
* m_share_account.status_enum, to show only active or only closed members; omit for
* every account whatever its status, which is what a register is
* @param officeId
* a single branch, for a branch-scoped login
*/
@GET
@Operation(summary = "Share accounts with their board resolution",
description = "One row per share account: member, branch, product, shares held, and the resolution number and date that authorised the allotment.")
public Response retrieveRegister(@QueryParam("status") final Integer status,
@QueryParam("officeId") final Long officeId) {
this.context.authenticatedUser();
// Every alias is double-quoted. PostgreSQL folds an unquoted alias to lower case, so
// queryForList would key the maps "accountno" and "resolutionnumber" - which serialise
// straight into the JSON and leave the client reading accountNo as undefined.
//
// Every date goes through to_char. Gson serialises a java.sql.Date with its default date
// format, which produced "Sept 14, 2026" - a locale-dependent string the client cannot
// parse, sort or compare, and which its ISO reader truncated to "Sept 14, 2". A date column
// is sent as an ISO string so it reads the same wherever it lands.
//
// resolution_date is selected but is null on every row today: the column exists and the
// frontend sends a value, but ShareAccount carries no @Column mapping for it, so the value
// is dropped on save. The meeting date that IS written lives on the purchase transaction,
// so it is read from there and the account's own date is used only as a fallback.
final StringBuilder sql = new StringBuilder("""
SELECT sa.id AS "shareAccountId",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.account_no AS "cifNumber",
c.display_name AS "clientName",
o.id AS "officeId",
o.name AS "officeName",
sp.name AS "productName",
sa.currency_code AS "currencyCode",
sa.status_enum AS "statusEnum",
sa.total_approved_shares AS "approvedShares",
sa.total_pending_shares AS "pendingShares",
sp.unit_price AS "unitPrice",
COALESCE(sa.total_approved_shares, 0) * COALESCE(sp.unit_price, 0) AS "shareValue",
sa.resolution_number AS "resolutionNumber",
to_char(COALESCE(sa.resolution_date, txn.first_meeting_date), 'YYYY-MM-DD') AS "resolutionDate",
txn.tranche_count AS "trancheCount",
to_char(sa.submitted_date, 'YYYY-MM-DD') AS "submittedDate",
to_char(sa.approved_date, 'YYYY-MM-DD') AS "approvedDate",
to_char(sa.activated_date, 'YYYY-MM-DD') AS "activatedDate",
to_char(sa.closed_date, 'YYYY-MM-DD') AS "closedDate"
FROM m_share_account sa
JOIN m_client c ON c.id = sa.client_id
LEFT JOIN m_office o ON o.id = c.office_id
LEFT JOIN m_share_product sp ON sp.id = sa.product_id
LEFT JOIN (
SELECT t.account_id,
MIN(t.meeting_date) AS first_meeting_date,
COUNT(*) AS tranche_count
FROM m_share_account_transactions t
WHERE t.is_active = true AND t.type_enum = 500
GROUP BY t.account_id
) txn ON txn.account_id = sa.id
WHERE 1 = 1
""");
final List<Object> args = new ArrayList<>();
if (status != null) {
sql.append(" AND sa.status_enum = ? ");
args.add(status);
}
if (officeId != null) {
sql.append(" AND o.id = ? ");
args.add(officeId);
}
// Resolution first: the register is read by resolution, and an account with no resolution
// recorded is the thing an auditor is looking for, so those sort to the top rather than
// being buried at the end of the list.
sql.append(" ORDER BY sa.resolution_number NULLS FIRST, sa.account_no ");
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray());
return Response.ok(gson.toJson(rows)).build();
}
}
@@ -109,6 +109,18 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
@Column(name = "resolution_number", nullable = false)
private String resolutionNumber;
/**
* Date of the board meeting that passed the resolution above.
*
* <p>
* The column has existed since 0470 and the frontend has been sending a value all along, but
* nothing mapped it - so every allotment was saved with the resolution number and without the
* date it was passed on, and the BOR register had nothing to show the Registrar. Nullable
* because the accounts already on file carry no date and cannot be given one retrospectively.
*/
@Column(name = "resolution_date")
private LocalDate resolutionDate;
@Column(name = "account_no", length = 20, unique = true, nullable = false)
private String accountNumber;
@@ -260,6 +272,19 @@ public class ShareAccount extends AbstractPersistableCustom<Long> {
return toReturn;
}
public LocalDate getResolutionDate() {
return this.resolutionDate;
}
public boolean setResolutionDate(final LocalDate resolutionDate) {
boolean toReturn = false;
if (this.resolutionDate == null || !this.resolutionDate.equals(resolutionDate)) {
this.resolutionDate = resolutionDate;
toReturn = true;
}
return toReturn;
}
public boolean setSavingsAccount(final SavingsAccount savingsAccount) {
boolean returnValue = false;
if (!this.savingsAccount.getId().equals(savingsAccount.getId())) {
@@ -238,6 +238,14 @@ public class ShareAccountDataSerializer {
minimumActivePeriod, minimumActivePeriodEnum, charges, submittedBy, submittedDate, approvedBy, approvedDate, rejectedBy,
rejectedDate, activatedBy, activatedDate, closedBy, closedDate, modifiedBy, modifiedDate);
// Set after construction rather than threaded through the constructor: it already takes
// twenty-eight arguments, and every caller would have to change to carry one optional date.
final LocalDate resolutionDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
if (resolutionDate != null) {
account.setResolutionDate(resolutionDate);
}
for (ShareAccountTransaction pur : sharesPurchased) {
pur.setShareAccount(account);
}
@@ -326,6 +334,14 @@ public class ShareAccountDataSerializer {
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.resolutiondate_paramname, element)) {
final LocalDate resolutionDate = this.fromApiJsonHelper
.extractLocalDateNamed(ShareAccountApiConstants.resolutiondate_paramname, element);
if (account.setResolutionDate(resolutionDate)) {
actualChanges.put(ShareAccountApiConstants.resolutiondate_paramname, resolutionDate);
}
}
if (this.fromApiJsonHelper.parameterExists(ShareAccountApiConstants.linkedloanid_paramname, element)) {
final Long linkedLoanId = this.fromApiJsonHelper.extractLongNamed(ShareAccountApiConstants.linkedloanid_paramname, element);
if (account.setLinkedLoanId(linkedLoanId)) {
@@ -85,4 +85,21 @@
<include file="parts/0546_aml_rule_direction_and_cash_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0547_aml_rule_subject_scope.xml" relativeToChangelogFile="true"/>
<include file="parts/0548_sponsor_dd_revoke_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0549_aml_alert_single_row_per_transaction.xml" relativeToChangelogFile="true"/>
<include file="parts/0550_client_risk_change_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0546_board_resolution_register.xml" relativeToChangelogFile="true"/>
<include file="parts/0551_aml_rule_coverage_classification.xml" relativeToChangelogFile="true"/>
<include file="parts/0552_activate_automatable_aml_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0553_client_behaviour_risk_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0554_aml_recurring_review_validity_and_tier.xml" relativeToChangelogFile="true"/>
<include file="parts/0555_aml_str_case_covered_alerts.xml" relativeToChangelogFile="true"/>
<include file="parts/0554_aml_alert_highest_severity.xml" relativeToChangelogFile="true"/>
<include file="parts/0555_low_severity_aml_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0556_aml_alert_rule_disposition.xml" relativeToChangelogFile="true"/>
<include file="parts/0557_aml_alert_raised_on_actual_time.xml" relativeToChangelogFile="true"/>
<include file="parts/0558_aml_alert_rule_directly_decided.xml" relativeToChangelogFile="true"/>
<include file="parts/0559_risk_policy_parameters.xml" relativeToChangelogFile="true"/>
<include file="parts/0560_client_kyc_risk_profile_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0561_add_client_behaviour_window_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0562_behaviour_window_distinct_months.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,129 @@
<?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.
-->
<!--
Register of Board Resolutions (BOR).
A co-operative society records every board decision as a numbered resolution in its minute book, and an auditor or
the Registrar asks to see that register. Until now the number existed only as free text typed into whichever screen
needed to quote it - Apply Shares and Approve Shares both ask for one - with nothing behind it to check against.
This table is that register. Each row is one resolution: the meeting it was passed at, who was present, what was
decided, and the number the society recorded it under.
resolution_number is the society's own, typed from the minute book rather than generated here: a co-operative
numbers its resolutions when it minutes them, so a number this system invented would not match the book it is meant
to mirror. financial_year and resolution_seq are kept alongside so a year's resolutions can be grouped and ordered
even when their numbers do not sort usefully.
-->
<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="0546-1">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_board_resolution"/></not>
</preConditions>
<createTable tableName="m_board_resolution">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="resolution_number" type="VARCHAR(50)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_board_resolution_number"/>
</column>
<column name="financial_year" type="VARCHAR(10)">
<constraints nullable="false"/>
</column>
<column name="resolution_seq" type="INT">
<constraints nullable="false"/>
</column>
<!-- The meeting. -->
<column name="meeting_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="meeting_type" type="VARCHAR(40)" defaultValue="BOARD_MEETING">
<constraints nullable="false"/>
</column>
<column name="meeting_place" type="VARCHAR(200)"/>
<column name="office_id" type="BIGINT"/>
<!-- What was decided. subject is the one-line description the register lists; resolution_text is the
wording as minuted, which is what actually carries authority. -->
<column name="subject" type="VARCHAR(300)">
<constraints nullable="false"/>
</column>
<column name="resolution_text" type="TEXT">
<constraints nullable="false"/>
</column>
<column name="category" type="VARCHAR(60)"/>
<!-- Attendance and the vote. Quorum is recorded as counted at the meeting rather than derived, because
the bye-laws that set it vary by society and change over time. -->
<column name="total_directors" type="INT"/>
<column name="members_present" type="INT"/>
<column name="quorum_met" type="BOOLEAN" defaultValueBoolean="true"/>
<column name="votes_for" type="INT"/>
<column name="votes_against" type="INT"/>
<column name="votes_abstained" type="INT"/>
<!-- Who signed it off. -->
<column name="proposed_by" type="VARCHAR(150)"/>
<column name="seconded_by" type="VARCHAR(150)"/>
<column name="chairperson_name" type="VARCHAR(150)"/>
<!-- APPROVED once passed; DRAFT while being minuted; CANCELLED when a later meeting rescinds it.
Superseding rather than deleting is the point: a resolution that was acted on cannot be removed from
the register just because it was later reversed. -->
<column name="status" type="VARCHAR(20)" defaultValue="APPROVED">
<constraints nullable="false"/>
</column>
<column name="effective_from" type="DATE"/>
<column name="remarks" type="VARCHAR(1000)"/>
<column name="created_by" type="BIGINT"/>
<column name="created_by_username" type="VARCHAR(100)"/>
<column name="created_at" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_at" type="TIMESTAMP WITHOUT TIME ZONE"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0546-2">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_board_resolution_year_seq"/></not>
</preConditions>
<!-- Serves the register's default listing, which is by year then entry order. -->
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_year_seq">
<column name="financial_year"/>
<column name="resolution_seq"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0546-3">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_board_resolution_meeting_date"/></not>
</preConditions>
<createIndex tableName="m_board_resolution" indexName="idx_board_resolution_meeting_date">
<column name="meeting_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- One transaction was raising one alert per rule it matched. A single 12,00,000 cash deposit
came back as two open alerts - the CTR and the new-account rule - and an officer had to
dispose of both to deal with one deposit. Worse, whitelisting one while escalating the other
left the same transaction recorded as simultaneously cleared and suspicious.
A transaction-monitoring system raises one alert per transaction, under the rule that most
warrants attention, and lists the others alongside it. That is what an analyst works from
and what a regulator expects to see in the case file: one decision per transaction. -->
<changeSet id="0549-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_alert" columnName="also_matched_rules"/></not>
</preConditions>
<comment>
The other rules the same transaction matched. Kept so nothing is lost when the alert is
reduced to one row - the analyst can still see that a deposit was both reportable and on
a new account, and the case file shows every rule that fired.
</comment>
<addColumn tableName="m_aml_alert">
<column name="also_matched_rules" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
<changeSet id="0549-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_aml_alert_txn"/></not>
</preConditions>
<comment>
Read on every evaluation to find an alert already raised for this transaction, so a
re-run does not raise a second one.
</comment>
<createIndex tableName="m_aml_alert" indexName="idx_aml_alert_txn">
<column name="transaction_type"/>
<column name="transaction_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- A customer's KYC risk category decides how often they are re-KYC'd and what their AML
thresholds are, so moving one is a decision about how closely the bank watches them. It
was only changeable by editing the KYC record directly - one officer, no second pair of
eyes, and no record of why.
The Risk Review Report already works out which customers are rated below what their
behaviour warrants. This lets the reviewer raise that change from the report itself and
sends it for approval, so the rating moves only once a checker agrees. -->
<changeSet id="0550-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_risk_change_request"/></not>
</preConditions>
<createTable tableName="m_client_risk_change_request">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Recorded as it stood when the request was raised, so an approval that lands after
the rating moved for some other reason can be seen to have been overtaken. -->
<column name="current_category" type="INT"/>
<column name="requested_category" type="INT">
<constraints nullable="false"/>
</column>
<column name="reason" type="VARCHAR(500)">
<constraints nullable="false"/>
</column>
<column name="status" type="VARCHAR(20)" defaultValue="PENDING">
<constraints nullable="false"/>
</column>
<column name="maker_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
<column name="checker_id" type="BIGINT"/>
<column name="checker_date" type="TIMESTAMP"/>
<column name="rejection_reason" type="VARCHAR(1000)"/>
</createTable>
</changeSet>
<changeSet id="0550-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="fk_risk_change_client"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_client_risk_change_request" baseColumnNames="client_id"
constraintName="fk_risk_change_client" referencedTableName="m_client" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0550-3" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_risk_change_status"/></not>
</preConditions>
<comment>The approval queue reads by status; the report reads by client.</comment>
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_status">
<column name="status"/>
</createIndex>
</changeSet>
<changeSet id="0550-4" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_risk_change_client"/></not>
</preConditions>
<createIndex tableName="m_client_risk_change_request" indexName="idx_risk_change_client">
<column name="client_id"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,186 @@
<?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.
-->
<!--
Says, for each of the 221 AML scenarios in the catalog, how the bank actually covers it.
Until now the catalog was a flat list and 207 of those scenarios had nothing behind them - no transaction rule, no
review step, nothing. They read on screen exactly like the 14 that were live, which is the worst of both worlds: an
inspector sees 221 scenarios "monitored", and an officer has no way to tell which ones the system is actually
watching.
A real AML function never claims to automate everything. It classifies:
AUTOMATED the engine evaluates it on every transaction
MANUAL_REVIEW no rule can decide it - an officer judges it, so it belongs on a review checklist
("customer could not explain source of funds", "adverse media report")
NOT_APPLICABLE the bank does not do this business at all, so the scenario cannot arise
(trade finance, foreign remittance, credit cards, lockers - none exist in this CBS)
PLANNED automatable in principle, but needs data the engine is not yet given
Recording the reason alongside is what makes this defensible. "Not monitored" invites a finding; "not applicable,
because this bank has no trade finance book" answers it.
-->
<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="0551-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_client_risk_rule" columnName="coverage_status"/></not>
</preConditions>
<addColumn tableName="m_client_risk_rule">
<column name="coverage_status" type="VARCHAR(20)"/>
<column name="coverage_note" type="VARCHAR(500)"/>
</addColumn>
</changeSet>
<!-- AUTOMATED: everything that already has a transaction rule behind it. -->
<changeSet author="fineract" id="0551-2">
<sql>
UPDATE m_client_risk_rule r
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = r.id);
</sql>
</changeSet>
<!--
NOT_APPLICABLE: business this bank does not transact.
A co-operative bank running this CBS has no trade finance book, no forex or cross-border remittance, no credit
card issuance and no locker module. The scenarios written for those cannot arise, and leaving them looking
unmonitored misrepresents the gap. Should the bank take on any of these lines, the classification is revisited
rather than the scenario being re-added.
-->
<changeSet author="fineract" id="0551-3">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No trade finance or import/export business is transacted on this CBS, so this scenario cannot arise.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Trade Based Money Laundering%' OR rule_name LIKE '%Overseas Direct Investment%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No cross-border remittance or foreign exchange business is transacted on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Afghan Drug Business%'
OR rule_name LIKE '%cross border%' OR rule_name LIKE '%Cross border%'
OR rule_name LIKE '%foreign remittance%' OR rule_name LIKE '%foreign inward%'
OR rule_name LIKE '%outward remittance%' OR rule_name LIKE '%Outward foreign%'
OR rule_name LIKE '%another country%' OR rule_name LIKE '%tax havens%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No credit card is issued on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Credit Card Monitoring%' OR rule_name LIKE '%credit card%');
UPDATE m_client_risk_rule
SET coverage_status = 'NOT_APPLICABLE',
coverage_note = 'No safe deposit locker module exists on this CBS.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND rule_name LIKE '%Locker%';
</sql>
</changeSet>
<!--
MANUAL_REVIEW: scenarios that turn on a judgement no rule can make.
Whether a customer "could not explain the source of funds", or whether a media report is adverse, is decided by
the officer in front of them. These are not failures of automation - they are the part of AML that is meant to
be human, and RBI's Master Direction describes them as matters for the principal officer's assessment.
-->
<changeSet author="fineract" id="0551-4">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'MANUAL_REVIEW',
coverage_note = 'Turns on an officer''s judgement at the counter or during periodic review; carried on the AML review checklist.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE 'Customer Due Diligence%'
OR rule_name LIKE 'Name Screening%'
OR rule_name LIKE '%Shell Companies%'
OR rule_name LIKE '%adverse media%' OR rule_name LIKE '%Adverse media%'
OR rule_name LIKE '%could not explain%'
OR rule_name LIKE '%does not reside%'
OR rule_name LIKE '%Complaint received%'
OR rule_name LIKE '%Mutilated%'
OR rule_name LIKE 'FICN%'
OR rule_name LIKE 'Terrorist Financing%'
OR rule_name LIKE 'Non Customer Monitoring%'
OR rule_name LIKE 'Additional RFIs Rule%'
OR rule_name LIKE '%Risk Categorization%');
</sql>
</changeSet>
<!--
PLANNED: automatable, but the engine is not yet given what it would need to decide them.
Counterparty identity (many-to-one), the account's dormancy state before the transaction, the turnover the
customer declared at KYC, and the channel a transaction arrived on are all absent from AmlTransactionContext.
Named here so the gap is visible and costed rather than discovered at an inspection.
-->
<changeSet author="fineract" id="0551-5">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs counterparty identity on the transaction context - not yet captured.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%Many to One%' OR rule_name LIKE '%One to Many%'
OR rule_name LIKE '%common phone%' OR rule_name LIKE '%joint holder%'
OR rule_name LIKE '%customers working together%'
OR rule_name LIKE '%Multiple accounts under the same name%'
OR rule_name LIKE '%unrelated parties%'
OR rule_name LIKE '%single Indian party%'
OR rule_name LIKE '%same remitter%'
OR rule_name LIKE '%several accounts%');
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs the customer''s declared turnover compared against actual activity - not yet computed.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%inconsistent with Profile%' OR rule_name LIKE '%inconsistent with client profile%'
OR rule_name LIKE 'Sudden %' OR rule_name LIKE '%surge in activity%'
OR rule_name LIKE '%High Net worth%');
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Needs the transaction channel (ATM, UPI, NEFT, RTGS, POS) on the context - not yet captured.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL
AND (rule_name LIKE '%ATM%' OR rule_name LIKE '%card usage%'
OR rule_name LIKE '%Non Home Branch%' OR rule_name LIKE '%multiple branches%');
</sql>
</changeSet>
<!--
Anything still unclassified is automatable with what the engine already has - a threshold, a direction, a window
and a subject. 0552 gives each of these a transaction rule; this marks them so the two migrations cannot drift.
-->
<changeSet author="fineract" id="0551-6">
<sql>
UPDATE m_client_risk_rule
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,387 @@
<?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.
-->
<!--
Gives a transaction rule to every catalog scenario the engine can already decide.
0551 classified the catalog; this acts on the AUTOMATED half of it. Each row below is a scenario that was described
in the catalog and never evaluated, expressed in the terms the engine understands - a type, a threshold, a
direction, a window, and who the rule is about.
Thresholds follow the reporting limits Indian banks work to: ten lakh is the CTR line, fifty thousand the PAN
quoting line under Rule 114B, and two lakh the cash acceptance ceiling under section 269ST. Where RBI's wording
gives no figure, the number is the one a co-operative bank of this size would set, and every one of them is
editable from the AML Rules Master afterwards - the point is that the scenario is evaluated at all, not that this
file guessed the bank's exact appetite.
Windows and counts are stated for pattern rules because a pattern needs both: three or more transactions inside a
band, not one large one. That distinction is what the engine's structuring and velocity paths test, and it is why a
single large deposit no longer reports as "splitting deposits below fifty thousand".
-->
<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="0552-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_DEPOSIT_DAY'</sqlCheck>
</preConditions>
<sql>
-- Helper: each INSERT links back to its catalog scenario by name, so the rule and the scenario it
-- implements stay connected and the register can show which is which.
-- ── Daily cash, by direction ────────────────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_DEPOSIT_DAY', 'High value cash deposits in a day', 'SAVINGS', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash deposits in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_DAY', 'High value cash withdrawals in a day', 'SAVINGS', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash withdrawals in a day' LIMIT 1;
-- Large cash debit. Separate from LARGE_CASH_TXN_25L, which is direction-agnostic: this is the catalog's
-- own withdrawal-side scenario and carries the lower 269ST-informed figure.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'LARGE_CASH_TXN_DEBIT', 'Large cash transaction (Debit)', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Large cash transaction(Debit)' LIMIT 1;
-- ── Monthly cash aggregates ─────────────────────────────────────────────────────────────────
-- STRUCTURING rather than THRESHOLD: the concern is a month's worth of deposits summing past the CTR
-- line, which is only a pattern if there were several of them.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_DEPOSIT_MONTH', 'High Value Cash Deposits in a month', 'SAVINGS', 1000000, 'STRUCTURING',
30, 3, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Deposits in a month' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHDRAWAL_MONTH', 'High Value Cash Withdrawals in a month', 'SAVINGS', 1000000, 'STRUCTURING',
30, 3, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Cash Withdrawals in a month' LIMIT 1;
-- ── Non-cash, daily and monthly ─────────────────────────────────────────────────────────────
-- cash_only false: these are deliberately the transfer/cheque counterparts of the rules above, which is
-- why the catalog lists them separately.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_DAY', 'High Value Non-Cash Deposits in a day', 'ALL', 2000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Non- Cash Deposits in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_DAY', 'High Value non-Cash Withdrawal in a day', 'ALL', 2000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value non- Cash Withdrawal in a day' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_DEPOSIT_MONTH', 'High Value non-cash Deposits in a month', 'ALL', 2000000, 'STRUCTURING',
30, 3, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value non-cash Deposits in a month' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHDRAWAL_MONTH', 'High value non-cash withdrawals in a month', 'ALL', 2000000, 'STRUCTURING',
30, 3, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value non-cash withdrawals in a month' LIMIT 1;
-- ── Credit entries and current accounts ─────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CREDIT_ENTRY_THRESHOLD', 'Threshold Limit in Credit entry', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Threshold Limit in Credit entry' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CURRENT_ACCOUNT_HIGH_VALUE', 'High Value Transactions in current account', 'CURRENT', 2500000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions in current account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CURRENT_ACCOUNT_UNUSUAL_ACTIVITY', 'Unusually high activity in Current Account', 'CURRENT', 5000000, 'VELOCITY',
30, 10, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Unusually high activity in Current Account(CA)' LIMIT 1;
-- ── New accounts ────────────────────────────────────────────────────────────────────────────
-- NEW_ACCOUNT scope keeps these off long-standing customers; the engine reads the account's opening date
-- from the context rather than inferring it from transaction history.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_HIGH_VALUE_TXN', 'High Value transactions in a new Account', 'ALL', 500000, 'THRESHOLD',
30, NULL, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions in a new Account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_HIGH_ACTIVITY', 'High activity in a new account', 'ALL', 200000, 'VELOCITY',
30, 5, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'High activity in a new account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_TXN_COUNT_MONTHLY', 'Number of Transactions in newly opened Accounts, monthly', 'ALL', 100000, 'VELOCITY',
30, 10, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'Number of Transactions in newly opened Accounts, monthly' LIMIT 1;
-- ── Subject-scoped: who the customer is ─────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REAL_ESTATE_ACCOUNT_MONITORING', 'Monitor accounts of parties in real estate', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Monitor accounts of parties%occupation related to Real Estate' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REAL_ESTATE_CASH_HIGH_VALUE', 'High value cash transactions - real estate agents and dealers', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions pertaining to accounts of real estate agents and dealers' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'PRECIOUS_METAL_DEALER_CASH', 'High value cash transactions by dealer in precious metal or stone', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions by dealer in precious metal or stone' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NPO_CASH_HIGH_VALUE', 'High value cash transactions in NPOs', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, true, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name = 'High value cash transactions in NPOs' LIMIT 1;
-- NTR territory: FIU-IND expects a non-profit receipt return at ten lakh, so the figure is not the
-- bank's to choose.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NGO_RECEIPT_OF_FUNDS', 'Registered NGOs - receipt of funds in account', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name = 'Account Monitoring - Reg. NGOs - Receipt of funds in account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NPO_ONWARD_DISBURSAL', 'NPOs receiving funds and transferring to multiple beneficiaries', 'ALL', 500000, 'VELOCITY',
30, 5, 'DEBIT', false, 'CHARITABLE_ORG', id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'NPOs or charities receiving funds%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_RISK_CUSTOMER_HIGH_VALUE', 'High Value transactions by high risk customers', 'ALL', 500000, 'THRESHOLD',
NULL, NULL, NULL, false, 'HIGH_RISK_BUSINESS', id, true
FROM m_client_risk_rule WHERE rule_name = 'High Value transactions by high risk customers' LIMIT 1;
-- Staff accounts. Left inactive and with no subject scope: the resolver knows PEP, charitable, high-risk
-- business and new-account only, so a STAFF scope would fall through to "matches everything" and report
-- this against every customer in the bank. Configured now, switched on when staff can be identified.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'STAFF_ACCOUNT_MONITORING', 'Monitoring accounts of Staff', 'ALL', 200000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'Monitoring accounts of Staff' LIMIT 1;
-- ── Loans and deposits ──────────────────────────────────────────────────────────────────────
-- Two lakh is section 269ST's ceiling on cash accepted in a single transaction, which is the line a cash
-- loan repayment is measured against.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'LOAN_CASH_REPAYMENT_WINDOW', 'Repayment of loan in cash over a window', 'LOAN', 200000, 'STRUCTURING',
90, 3, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Repayment of loan in cash greater than%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'FDR_PREMATURE_CLOSURE_LARGE', 'Premature closure of large FDR through PO/DD', 'DEPOSIT', 1000000, 'THRESHOLD',
NULL, NULL, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Premature closure of large FDR through PO/DD' LIMIT 1;
-- ── Instruments and inward funds ────────────────────────────────────────────────────────────
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'DD_DEPOSIT_IMMEDIATE_WITHDRAWAL', 'Multiple deposit of MCs/DDs followed by immediate withdrawal', 'ALL', 500000, 'VELOCITY',
7, 3, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Multiple deposit of Mcs/DDs%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NUMEROUS_CREDIT_SLIPS', 'Numerous small credit slips totalling a substantial amount', 'SAVINGS', 500000, 'STRUCTURING',
7, 5, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Depositing cash by means of numerous credit slip%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_REMITTANCE_HIGH_VALUE', 'High value inward remittance', 'TRANSFER', 1000000, 'THRESHOLD',
NULL, NULL, 'CREDIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High value inward remittance' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_REMITTANCE_NEW_ACCOUNT', 'Inward remittance in a new account', 'TRANSFER', 200000, 'THRESHOLD',
30, NULL, 'CREDIT', false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'Inward remittance in a new account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEFT_HIGH_VALUE', 'Monitoring NEFT transactions', 'TRANSFER', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Monitoring NEFT Transactions' LIMIT 1;
-- ── Frequency ───────────────────────────────────────────────────────────────────────────────
-- The catalog lists this scenario twice; FREQUENT_LOW_CASH_WITHDRAWAL already covers the first. This is
-- the monthly counterpart, over a longer window.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, min_limit_amount, max_limit_amount, source_client_risk_rule_id, is_active)
SELECT 'FREQUENT_LOW_CASH_WITHDRAWAL_MONTH', 'Frequent low cash withdrawals (monthly)', 'SAVINGS', 300000, 'VELOCITY',
30, 8, 'DEBIT', true, NULL, 1, 50000, id, true
FROM m_client_risk_rule WHERE rule_name = 'Frequent low cash withdrawals'
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t WHERE t.source_client_risk_rule_id = m_client_risk_rule.id)
LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'CHEQUE_LEAVES_HIGH_USAGE', 'High number of cheque leaves', 'ALL', 100000, 'VELOCITY',
30, 20, 'DEBIT', false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'High number of cheque leaves' LIMIT 1;
-- Round-figure transfers, a laundering tell the catalog describes in foreign currency but which reads
-- the same in rupees. Banded to the round lakh so ordinary amounts do not match.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, min_limit_amount, source_client_risk_rule_id, is_active)
SELECT 'ROUND_FIGURE_TRANSFERS', 'Transfers in round multiples, repeated', 'TRANSFER', 1000000, 'VELOCITY',
30, 5, NULL, false, NULL, 100000, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Amounts of money transfer carried out%' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'INWARD_THEN_IMMEDIATE_OUTFLOW', 'Inward remittance followed by immediate withdrawal or transfer', 'ALL', 500000, 'VELOCITY',
2, 3, NULL, false, NULL, id, true
FROM m_client_risk_rule WHERE rule_name LIKE 'Account Monitoring - Inward remittance followed by immediate%' LIMIT 1;
</sql>
</changeSet>
<!--
PAN-quoting scenarios. Rule 114B requires a PAN on cash deposits of fifty thousand or more, so these are about
the absence of one - a condition the engine cannot yet read from the transaction context.
Left inactive rather than omitted: the rule exists with the right threshold and wording, so activating it is a
switch once the context carries the customer's PAN status, and until then the register shows exactly why it is
off rather than the scenario silently missing.
-->
<changeSet author="fineract" id="0552-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_WITHOUT_PAN'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_CASH_WITHOUT_PAN', 'High value cash transactions in accounts without PAN', 'ALL', 50000, 'THRESHOLD',
NULL, NULL, NULL, true, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'High value Cash transactions in accounts without PAN' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_NONCASH_WITHOUT_PAN', 'High value non-cash transactions in accounts without PAN', 'ALL', 1000000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'high value non cash transactions in accounts without PAN' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, rule_type, window_days, min_transaction_count,
direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'HIGH_VALUE_TXN_WITHOUT_PAN_ID', 'High Value Transactions without PAN ID', 'ALL', 50000, 'THRESHOLD',
NULL, NULL, NULL, false, NULL, id, false
FROM m_client_risk_rule WHERE rule_name = 'High Value Transactions without PAN ID' LIMIT 1;
</sql>
</changeSet>
<!--
The rules seeded above are now backed; mark their scenarios AUTOMATED so the register reflects what the engine
actually evaluates. The PAN rules stay PLANNED - they exist but are switched off, and calling them automated
would be the same overstatement this whole exercise is correcting.
-->
<changeSet author="fineract" id="0552-3">
<sql>
UPDATE m_client_risk_rule r
SET coverage_status = 'AUTOMATED',
coverage_note = 'Evaluated by the AML engine on every matching transaction.'
WHERE r.rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = true);
UPDATE m_client_risk_rule r
SET coverage_status = 'PLANNED',
coverage_note = 'Rule is configured with its threshold but switched off, because the engine cannot yet '
|| 'identify this scenario''s subject from the transaction context.'
WHERE r.rule_kind = 'AML'
AND EXISTS (SELECT 1 FROM m_aml_transaction_rule t
WHERE t.source_client_risk_rule_id = r.id AND t.is_active = false)
AND NOT EXISTS (SELECT 1 FROM m_aml_transaction_rule t2
WHERE t2.source_client_risk_rule_id = r.id AND t2.is_active = true);
-- Anything still unclassified after 0551 and this file is automatable in principle but has no rule yet.
UPDATE m_client_risk_rule
SET coverage_status = 'PLANNED',
coverage_note = 'Automatable, but no transaction rule has been configured for it yet.'
WHERE rule_kind = 'AML' AND coverage_status IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- Risk ratings were being suggested off a single alerted transaction. A High-risk customer who
bought something small enough to trip nothing came back as "lower to Low", and a Low-risk
customer moving large sums repeatedly came back as nothing at all, because the report only
ever saw the transactions a rule had already fired on - never the customer's actual
behaviour.
A rating is about the customer, not about one transaction. This records what each customer
actually did over a period - every transaction, not only the alerted ones - and compares it
with the income they declared at onboarding. That comparison is what a periodic KYC review
is, and it is the only thing that can honestly say a rating should move.
Raising and lowering are deliberately not symmetric. A rating goes up on evidence and comes
down only on a clean period: getting an increase wrong costs a customer some extra
monitoring, getting a decrease wrong takes the monitoring off someone who needed it. -->
<changeSet id="0553-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_client_behaviour_window"/></not>
</preConditions>
<comment>
One row per customer per closed observation window. Written by the review job, never by
a transaction - a single transaction is an input to a window, not a window of its own.
</comment>
<createTable tableName="m_client_behaviour_window">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="client_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="window_from" type="DATE">
<constraints nullable="false"/>
</column>
<column name="window_to" type="DATE">
<constraints nullable="false"/>
</column>
<!-- Every transaction in the window, not only the ones that tripped a rule. -->
<column name="txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="credit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="debit_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="largest_txn" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Cash is watched separately: the same turnover in cash and by transfer are not the
same risk, because cash carries no counterparty trail. -->
<column name="cash_txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="cash_total" type="DECIMAL(19,6)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- Transactions at or above the CTR limit, which are reportable in their own right. -->
<column name="large_txn_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<!-- What the customer said they earn, copied in as it stood: a later edit to the client
record must not silently rewrite a window that was already assessed. -->
<column name="declared_annual_income" type="DECIMAL(19,6)"/>
<column name="declared_annual_turnover" type="DECIMAL(19,6)"/>
<!-- Window credits, annualised, over declared income. Above 1 means the customer moved
more than they said they earn. -->
<column name="income_ratio" type="DECIMAL(19,6)"/>
<column name="alert_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="str_case_count" type="INT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="risk_category_at_review" type="INT"/>
<!-- UP / DOWN / HOLD, with the sentence explaining it. -->
<column name="suggested_direction" type="VARCHAR(10)"/>
<column name="suggested_category" type="INT"/>
<column name="suggestion_reason" type="VARCHAR(600)"/>
<column name="computed_on" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet id="0553-2" author="CBS">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="fk_behaviour_window_client"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_client_behaviour_window" baseColumnNames="client_id"
constraintName="fk_behaviour_window_client" referencedTableName="m_client" referencedColumnNames="id"/>
</changeSet>
<changeSet id="0553-3" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_behaviour_window_client"/></not>
</preConditions>
<comment>Read per customer, newest first, by both the report and the next run of the job.</comment>
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_client">
<column name="client_id"/>
<column name="window_to"/>
</createIndex>
</changeSet>
<changeSet id="0553-4" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_behaviour_window_direction"/></not>
</preConditions>
<comment>The review worklist reads the customers whose rating the last window says should move.</comment>
<createIndex tableName="m_client_behaviour_window" indexName="idx_behaviour_window_direction">
<column name="suggested_direction"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.1.xsd">
<!-- An alert reports the severity of the rule it was raised under, and that is the only rule
the screen can see. Since one transaction now raises one alert under whichever rule most
warrants attention, a high-severity rule can end up in also_matched_rules - not because it
was less serious, but because a statutory CTR outranked it.
An officer sweeping up low and medium alerts for one batch decision would then be offered
a transaction that had tripped a high-severity rule, with nothing on the row to say so.
This records the highest severity across every rule the transaction matched, so the screen
can hold back exactly those. -->
<changeSet id="0554-1" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_alert" columnName="highest_severity"/></not>
</preConditions>
<comment>
1 Low, 2 Medium, 3 High — the worst band among all rules this transaction matched, not
only the governing one. Null where no matched rule carried a severity at all, which is
how the CTR rules arrive: they are returns the bank owes, not graded suspicions.
</comment>
<addColumn tableName="m_aml_alert">
<column name="highest_severity" type="INT"/>
</addColumn>
</changeSet>
<changeSet id="0554-2" author="CBS">
<comment>
Backfill from the governing rule for alerts raised before this column existed. Their
also_matched rules are recorded as codes rather than ids, so the governing rule's own
severity is the most that can honestly be said about them.
</comment>
<sql>
UPDATE m_aml_alert a
SET highest_severity = (
SELECT cr.risk_category
FROM m_aml_transaction_rule r
JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
WHERE r.id = a.rule_id)
WHERE a.highest_severity IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,61 @@
<?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.
-->
<!--
Gives a recurring-alert review a life of its own, and records how severe the customer's repetition had become when
it was taken.
A review used to hold the customer out of the queue only until their alert count rose by one. A customer whose
ordinary business trips a rule every few days therefore came back the day after being reviewed, and the officer
re-read the same explanation they had written themselves. Nothing about the response changed however many times
this happened, so a customer surfacing for the fortieth time was handled exactly like one surfacing for the first.
valid_until the review stands until this date; the customer stays out of the queue meanwhile unless their
alert count grows past the escalation tier's own trigger
escalation_tier what the count had reached when reviewed - ROUTINE, ELEVATED, SENIOR or MANDATORY_STR - so the
history reads as a progression rather than a flat list of identical reviews
alerts_actioned how many of the customer's open alerts this review actually disposed of, which is what separates
a review that did something from one that only recorded an opinion
-->
<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="0554-1">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_aml_recurring_review" columnName="valid_until"/></not>
</preConditions>
<addColumn tableName="m_aml_recurring_review">
<column name="valid_until" type="DATE"/>
<column name="escalation_tier" type="VARCHAR(20)"/>
<column name="alerts_actioned" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
<!--
Existing reviews keep behaving as they did: no validity date means the old "any new alert brings them back"
rule still applies to them, rather than back-dating a window they were never taken under.
-->
<changeSet author="fineract" id="0554-2">
<sql>
UPDATE m_aml_recurring_review SET escalation_tier = 'ROUTINE' WHERE escalation_tier IS NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?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.
-->
<!--
Lets one STR case cover several alerts.
A case has always pointed at exactly one alert, which is right when an officer escalates a single transaction they
find suspicious. It is wrong for a case raised off a customer's repeated behaviour: escalating a recurring-activity
review opened one case per open alert, so a customer reviewed at four alerts put four cases in front of the
Principal Officer, each describing one transaction in a pattern that only means anything taken together. Reviewed
again at nine, they would put five more.
That is also not how the report itself works. An STR to FIU-IND describes suspicious *activity* - the pattern, the
customer, and the transactions that make it up - so the officer investigating needs them in one place, not split
across cases that each hold a fragment.
m_aml_str_case.alert_id stays as it is: it remains the alert the case was opened from, which keeps every existing
case, query and screen working unchanged. This table records the rest of what a case covers, so a case opened from
one transaction reads exactly as it does today, and one opened from a pattern carries all of it.
-->
<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="0555-1">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_aml_str_case_alert"/></not>
</preConditions>
<createTable tableName="m_aml_str_case_alert">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="str_case_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="alert_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="added_on_utc" type="TIMESTAMP WITHOUT TIME ZONE" defaultValueComputed="now()">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="0555-2">
<preConditions onFail="MARK_RAN">
<not><foreignKeyConstraintExists foreignKeyName="FK_str_case_alert_case"/></not>
</preConditions>
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="str_case_id"
constraintName="FK_str_case_alert_case"
referencedTableName="m_aml_str_case" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_aml_str_case_alert" baseColumnNames="alert_id"
constraintName="FK_str_case_alert_alert"
referencedTableName="m_aml_alert" referencedColumnNames="id"/>
</changeSet>
<!--
One alert belongs to at most one case. Without this a second escalation of the same transaction could attach it
to a second case, and the same transaction would reach FIU-IND twice under two references.
-->
<changeSet author="fineract" id="0555-3">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="uq_str_case_alert"/></not>
</preConditions>
<createIndex tableName="m_aml_str_case_alert" indexName="uq_str_case_alert" unique="true">
<column name="alert_id"/>
</createIndex>
</changeSet>
<changeSet author="fineract" id="0555-4">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_str_case_alert_case"/></not>
</preConditions>
<createIndex tableName="m_aml_str_case_alert" indexName="idx_str_case_alert_case">
<column name="str_case_id"/>
</createIndex>
</changeSet>
<!--
Existing cases each cover the one alert they were opened from, so the link table describes them too and nothing
has to ask "which kind of case is this" when reading coverage.
-->
<changeSet author="fineract" id="0555-5">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_str_case_alert</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_str_case_alert (str_case_id, alert_id, added_on_utc)
SELECT id, alert_id, now() FROM m_aml_str_case;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?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.
-->
<!--
Gives the catalog a low-severity band for transaction behaviour.
The catalog held 19 Low entries and not one of them could raise an alert: every Low row is rule_kind 'KYC', which
describes who a customer is - a student, a farmer, a government employee - and never what a transaction looks like.
All 221 AML-kind rules sat in Medium or High. So the engine could not produce a Low alert at all, the Low Risk tile
on the Flagged Transactions screen was permanently nil, and an officer had only two settings: ignore, or treat as a
suspicion worth disposing of.
That gap matters beyond the empty tile. Monitoring needs somewhere to put activity that is worth recording and is
not worth an officer's suspicion - the first slightly-large withdrawal on a quiet account, a run of small cash
credits, a round-sum payment. Real CBS monitoring logs these to build a behavioural picture over time; they become
evidence when something else happens on the same account, and they are noise if each one demands a decision. Low is
that place, and it is what makes batch disposition useful: many small observations, one decision.
Bands sit strictly below the Medium rules they shadow, so an amount belongs to exactly one severity. Cash movement
of fifty thousand and above in a repeating pattern is Medium (FREQUENT_LOW_CASH_*, band 50k-2L, three in seven
days); a single such transaction is not covered there at all, and that single transaction is what MODERATE_CASH_*
records. The frequency rule below stops at 49,999 so it cannot collide with the Medium band.
-->
<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">
<!-- The catalog scenarios. Written as AML-kind Low rows so they can source a transaction rule, which the
existing KYC-kind Low rows cannot. -->
<changeSet author="fineract" id="0555-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_kind = 'AML' AND risk_category = 1</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_client_risk_rule (rule_name, risk_category, rule_kind, reason, order_position, aml_check_status, coverage_status)
VALUES
('Moderate cash deposit on a savings account', 1, 'AML',
'Cash deposit at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 601, 'FLAGGED', 'AUTOMATED'),
('Moderate cash withdrawal on a savings account', 1, 'AML',
'Cash withdrawal at or above the PAN quoting line but below the reporting band - recorded to build the account ordinary pattern', 602, 'FLAGGED', 'AUTOMATED'),
('Repeated small cash deposits in a week', 1, 'AML',
'Several small cash deposits inside a week - ordinary for a trader, worth recording for anyone else', 603, 'FLAGGED', 'AUTOMATED'),
('First sizeable transaction on a new account', 1, 'AML',
'A recently opened account moving money for the first time - normal after a salary or a festival, and the point at which monitoring should start watching', 604, 'FLAGGED', 'AUTOMATED');
</sql>
</changeSet>
<!-- The transaction rules that implement them. Bands stop below the Medium rules so no amount is graded twice. -->
<changeSet author="fineract" id="0555-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MODERATE_CASH_DEPOSIT'</sqlCheck>
</preConditions>
<sql>
-- Rs 50,000 to Rs 2,00,000 in cash, one transaction. Above the PAN quoting line under Rule 114B, below
-- the Rs 10,00,000 CTR line. The Medium rule over the same band asks for three inside a week; a single
-- one was previously recorded nowhere.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'MODERATE_CASH_DEPOSIT', 'Moderate cash deposit', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
NULL, NULL, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash deposit on a savings account' LIMIT 1;
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'MODERATE_CASH_WITHDRAWAL', 'Moderate cash withdrawal', 'SAVINGS', 50000, 50000, 200000, 'THRESHOLD',
NULL, NULL, 'DEBIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Moderate cash withdrawal on a savings account' LIMIT 1;
-- Four or more cash credits each under Rs 50,000 inside a week, totalling Rs 1,00,000 or more.
-- Deliberately capped at 49,999 so it cannot overlap the Medium band that begins at 50,000: this is
-- frequency in small change, which is a shopkeeper ordinary week and only matters if it persists.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'REPEATED_SMALL_CASH_DEPOSIT', 'Repeated small cash deposits in a week', 'SAVINGS', 100000, 1, 49999,
'VELOCITY', 7, 4, 'CREDIT', true, NULL, id, true
FROM m_client_risk_rule WHERE rule_name = 'Repeated small cash deposits in a week' LIMIT 1;
-- Rs 25,000 up to Rs 5,00,000 on an account opened within the last 180 days. NEW_ACCOUNT is the scope the
-- resolver already understands; the ceiling is where NEW_ACCOUNT_LARGE_TXN (Medium) takes over, so the
-- two never both fire.
INSERT INTO m_aml_transaction_rule
(rule_code, description, applies_to, threshold_amount, min_limit_amount, max_limit_amount, rule_type,
window_days, min_transaction_count, direction, cash_only, subject_scope, source_client_risk_rule_id, is_active)
SELECT 'NEW_ACCOUNT_FIRST_ACTIVITY', 'First sizeable transaction on a new account', 'SAVINGS', 25000,
25000, 499999, 'THRESHOLD', NULL, NULL, NULL, false, 'NEW_ACCOUNT', id, true
FROM m_client_risk_rule WHERE rule_name = 'First sizeable transaction on a new account' LIMIT 1;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,120 @@
<?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.
-->
<!--
Lets an officer dispose of the individual rules a transaction matched, not only the transaction as a whole.
One transaction raises one alert, under the rule that most warrants attention, and the rest are recorded alongside it.
That is right for reporting - a transaction is one case - but it left the officer with one verdict covering everything
that fired. A deposit can trip six rules where five are explainable by the customer's declared business and the sixth
is not; recording "whitelisted" against all six loses exactly the observation that mattered, and recording
"escalated" against all six sends five findings to the STR that do not belong there.
This table holds a decision per rule. The alert keeps its own status - it is still the case, and it is what the CTR
and STR registers read - but it is now derived from the rules beneath it: every rule cleared means the alert is
whitelisted, any rule escalated means the alert is escalated, and anything in between leaves the alert open with
part of it decided.
-->
<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="0556-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_aml_alert_rule"/>
</not>
</preConditions>
<createTable tableName="m_aml_alert_rule">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="alert_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- The code rather than a foreign key to m_aml_transaction_rule: the alert is a record of what was
detected at the time, and editing or retiring a rule afterwards must not rewrite that history. -->
<column name="rule_code" type="VARCHAR(100)">
<constraints nullable="false"/>
</column>
<!-- Copied at the time of the decision for the same reason. A rule re-banded later does not change what
the officer was looking at when they decided. -->
<column name="risk_category" type="INT"/>
<!-- True for the rule the alert itself was raised under. -->
<column name="is_governing" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<!-- NULL until decided; then WHITELIST or ESCALATE. -->
<column name="disposition" type="VARCHAR(20)"/>
<column name="reviewed_by" type="BIGINT"/>
<column name="reviewed_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
<column name="review_notes" type="VARCHAR(1000)"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="alert_id"
constraintName="fk_aml_alert_rule_alert" referencedTableName="m_aml_alert"
referencedColumnNames="id" onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_aml_alert_rule" baseColumnNames="reviewed_by"
constraintName="fk_aml_alert_rule_reviewer" referencedTableName="m_appuser"
referencedColumnNames="id"/>
<!-- A rule appears once per alert: the same rule cannot be both cleared and escalated on one transaction. -->
<addUniqueConstraint tableName="m_aml_alert_rule" columnNames="alert_id, rule_code"
constraintName="uq_aml_alert_rule"/>
<createIndex tableName="m_aml_alert_rule" indexName="idx_aml_alert_rule_alert">
<column name="alert_id"/>
</createIndex>
</changeSet>
<!-- Backfill: every alert already raised gets a row per rule it matched, so the expanded pane is populated for
history as well as for alerts raised from here on. An alert already decided has that decision copied down to
each of its rules - it was made about all of them, and leaving them blank would show a closed alert as
undecided. -->
<changeSet author="fineract" id="0556-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_aml_alert_rule</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
reviewed_on, review_notes)
SELECT a.id, r.rule_code, cr.risk_category, true, a.disposition, a.reviewed_by, a.reviewed_on, a.review_notes
FROM m_aml_alert a
JOIN m_aml_transaction_rule r ON r.id = a.rule_id
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id;
INSERT INTO m_aml_alert_rule (alert_id, rule_code, risk_category, is_governing, disposition, reviewed_by,
reviewed_on, review_notes)
SELECT a.id, code.rule_code, cr.risk_category, false, a.disposition, a.reviewed_by, a.reviewed_on,
a.review_notes
FROM m_aml_alert a
CROSS JOIN LATERAL unnest(string_to_array(a.also_matched_rules, ',')) AS raw(rule_code)
CROSS JOIN LATERAL (SELECT trim(raw.rule_code) AS rule_code) AS code
LEFT JOIN m_aml_transaction_rule r ON r.rule_code = code.rule_code
LEFT JOIN m_client_risk_rule cr ON cr.id = r.source_client_risk_rule_id
WHERE a.also_matched_rules IS NOT NULL
AND a.also_matched_rules &lt;&gt; ''
AND trim(raw.rule_code) &lt;&gt; ''
ON CONFLICT (alert_id, rule_code) DO NOTHING;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?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.
-->
<!--
Gives already-raised alerts the time they were actually raised.
raised_on was being set from the transaction's value date - a LocalDate widened with atStartOfDay() - so every alert
ever raised sat at midnight. The feed is ordered by it and the screen prints it, so alerts raised minutes apart were
indistinguishable and every row read "00:00". The engine now records the moment the alert is raised; this repairs the
rows written before that.
created_on_utc is the auditing timestamp written when the row was inserted, which is the time being recovered. Only
rows still sitting exactly at midnight are touched, so anything already correct is left alone and the changeset is
safe to re-run.
-->
<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="0557-1">
<sql>
UPDATE m_aml_alert
SET raised_on = created_on_utc
WHERE created_on_utc IS NOT NULL
AND raised_on = date_trunc('day', raised_on);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,58 @@
<?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.
-->
<!--
Separates the rule an officer actually decided from the rules that were carried with it.
Marking one rule suspicious escalates the whole transaction, and every other rule on it is closed under that same
escalation - the transaction is one case, so nothing can be left open behind a filed STR. But the record then read as
though the officer had examined and condemned all of them, which is not what happened and is not what an audit of the
decision should show. On a transaction that tripped six rules, knowing which one the officer put their name to is the
whole substance of the finding.
This flag carries that: true where the officer named the rule, false where the system closed it to keep the case
consistent. Existing rows are backfilled on the same principle - a rule closed by a whole-alert decision was decided
by the officer, because there they acted on the transaction as a whole.
-->
<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="0558-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_aml_alert_rule" columnName="directly_decided"/>
</not>
</preConditions>
<addColumn tableName="m_aml_alert_rule">
<column name="directly_decided" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- Everything decided before this column existed was decided through a whole-alert action, which is a direct
decision on every rule under it. -->
<changeSet author="fineract" id="0558-2">
<sql>
UPDATE m_aml_alert_rule SET directly_decided = true WHERE disposition IS NOT NULL;
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,109 @@
<?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.
-->
<!--
The numbers the risk re-rating policy is made of, held as data rather than in code.
These are the bank's risk appetite, not engineering constants: how long a customer must behave before a High rating
comes down, how much activity counts as a pattern rather than a few entries. A co-operative bank whose members are
farmers transacting three times a month needs different figures from one lending to traders, and the compliance
function - not a developer - should be able to set them.
The asymmetry in the seeded values is deliberate and is the substance of the policy. Suspicion moves a rating up on
90 days of evidence; trust brings it down only after 365. Nothing moves a rating on a single transaction in either
direction - a pattern needs several, which is the same test the transaction rules themselves apply.
-->
<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="0559-1">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_risk_policy_parameter"/>
</not>
</preConditions>
<createTable tableName="m_risk_policy_parameter">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="parameter_key" type="VARCHAR(80)">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_risk_policy_parameter_key"/>
</column>
<column name="parameter_value" type="NUMERIC(19,2)">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(500)"/>
<column name="last_modified_by" type="BIGINT"/>
<column name="last_modified_on" type="TIMESTAMP WITHOUT TIME ZONE"/>
</createTable>
</changeSet>
<changeSet author="fineract" id="0559-2">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_risk_policy_parameter</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_risk_policy_parameter (parameter_key, parameter_value, description) VALUES
-- Upgrades: recent evidence, and still a pattern rather than one transaction.
('UPGRADE_WINDOW_DAYS', 90,
'Days of activity examined when considering raising a rating. Short because a concern is about what the customer is doing now.'),
('MIN_TXN_FOR_UPGRADE', 5,
'Transactions required in the upgrade window before a rating may be raised. One large transaction is an alert, not a pattern.'),
('LOW_TO_MED_CASH_TOTAL', 2500000,
'Cash through a Low-rated customer in the upgrade window that warrants Medium.'),
('LOW_TO_MED_CTR_COUNT', 3,
'CTR-reportable transactions in the upgrade window that warrant Medium.'),
('LOW_TO_MED_ALERT_COUNT', 3,
'Medium-or-worse alerts in the upgrade window that warrant Medium.'),
('MED_TO_HIGH_CASH_TOTAL', 10000000,
'Cash through a Medium-rated customer in the upgrade window that warrants High.'),
('MED_TO_HIGH_ALERT_COUNT', 5,
'High-severity alerts in the upgrade window that warrant High.'),
-- Downgrades: long, evenly spread, and materially larger volumes of evidence.
('HIGH_TO_MEDIUM_WINDOW_DAYS', 365,
'Days of clean behaviour before a High rating may come down. A year, because removing High is a judgement that the earlier concern no longer holds.'),
('HIGH_TO_MEDIUM_MIN_TXN', 20,
'Transactions required in that year, so the rating is lowered on demonstrated behaviour rather than on quiet.'),
('HIGH_TO_MEDIUM_MIN_MONTHS', 6,
'Distinct calendar months the transactions must span. Twenty in one week is not a year of good conduct.'),
('MEDIUM_TO_LOW_WINDOW_DAYS', 270,
'Days of clean behaviour before a Medium rating may come down to Low.'),
('MEDIUM_TO_LOW_MIN_TXN', 12,
'Transactions required in that window.'),
('MEDIUM_TO_LOW_MIN_MONTHS', 5,
'Distinct calendar months those transactions must span.'),
-- Materiality: what size of activity holds a rating up regardless of the count.
('SMALL_TXN_MAX', 100000,
'Largest single entry below which the period reads as small-value activity.'),
('SMALL_TOTAL_MAX', 500000,
'Period total below which activity is immaterial for an individual customer.'),
('MATERIAL_TOTAL', 2500000,
'Period total at or above which volume alone holds the rating where it is.'),
-- Blocks: conditions under which no downgrade is offered at all.
('STR_BLOCK_MONTHS', 24,
'Months after an STR during which the customer''s rating may not be lowered.');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?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.
-->
<!--
The customer facts the re-rating policy tests against, which the KYC record did not hold.
Two of the policy's blocks are about who the customer is rather than what they transacted. A politically exposed
person is not downgraded because their activity looks ordinary - that is precisely the risk - and the same holds for
a sanctions or adverse-media match. Neither could be tested, because the KYC record had nowhere to say so, and
m_client_behaviour_window already carries an income_ratio column with no declared income anywhere to compute it
from.
Every column is nullable and defaults to the benign value. A blank pep flag means "not recorded as a PEP", which is
the correct reading for an existing book where nobody was ever asked; it does not silently assert the customer is
clean, and the review screen shows the figures the suggestion was based on so an officer can see what was and was
not known.
-->
<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="0560-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_kyc" columnName="is_pep"/>
</not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="is_pep" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0560-2">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_kyc" columnName="sanctions_match"/>
</not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="sanctions_match" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="adverse_media_match" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<!-- Declared figures: what the customer told the bank to expect, which is what "turnover out of keeping with the
declared income" is measured against. -->
<changeSet author="fineract" id="0560-3">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_kyc" columnName="declared_annual_income"/>
</not>
</preConditions>
<addColumn tableName="m_client_kyc">
<column name="declared_annual_income" type="NUMERIC(19,6)"/>
<column name="declared_annual_turnover" type="NUMERIC(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,60 @@
<?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.
-->
<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.9.xsd">
<!--
Registers the nightly pass that works out what each customer's money did over the review period and what that
says about their rating.
Scheduled at 02:00, after the KYC restriction pass at 00:10, because a customer whose KYC lapsed overnight must
read as overdue when the policy tests whether their rating may be lowered - the answer is no, and the earlier
job is what establishes it.
The job writes suggestions only. Moving a rating stays a decision an officer makes and a checker approves.
-->
<changeSet author="fineract" id="0561-1">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Client Behaviour Window Computation'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Client Behaviour Window Computation"/>
<column name="display_name" value="Client Behaviour Window Computation"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="CLT_BHVR"/>
<column name="cron_expression" value="0 0 2 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Client Behaviour Window ComputationJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,43 @@
<?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.
-->
<!--
How many separate calendar months the window's transactions fall in.
The downgrade rules test spread as well as count: twenty transactions inside one week is not a year of steady
conduct, and a rating lowered on that reading would be lowered on a burst of activity rather than on consistency.
The policy computes the figure either way; storing it is what lets the review screen show the officer the same
measure the recommendation was made on.
-->
<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="0562-1">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_client_behaviour_window" columnName="distinct_months"/>
</not>
</preConditions>
<addColumn tableName="m_client_behaviour_window">
<column name="distinct_months" type="INT"/>
</addColumn>
</changeSet>
</databaseChangeLog>