diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 8b98ac771..ee5b62605 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java index 2d3b1c585..ac63dcd4d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlert.java @@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "disposition", length = 20) private String disposition; + /** + * The other rules this same transaction matched, comma-separated. + * + *

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

+ * 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 { 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(); diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java index e9eab000b..1fbefbc3f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRepository.java @@ -44,4 +44,16 @@ public interface AmlAlertRepository extends JpaRepository { * twice, which would put two STR cases in front of the Principal Officer for a single transaction. */ List findByRule_IdAndTransactionId(Long ruleId, Long transactionId); + + /** + * A customer's still-open alerts inside the observation window, oldest first. + * + *

+ * 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 findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java new file mode 100644 index 000000000..8886cc528 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRule.java @@ -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. + * + *

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

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

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java new file mode 100644 index 000000000..0cc7940c4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlAlertRuleRepository.java @@ -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 { + + List findByAlertId(Long alertId); + + /** Every finding across a page of alerts, in one read rather than one query per alert. */ + List findByAlertIdIn(Collection alertIds); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java new file mode 100644 index 000000000..ca99b3158 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringEscalationTier.java @@ -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. + * + *

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

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

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java index dcab2fa94..911b471ab 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java @@ -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. + * + *

+ * 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(); + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java new file mode 100644 index 000000000..b5168b9cc --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlert.java @@ -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. + * + *

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java new file mode 100644 index 000000000..8c807f10c --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseAlertRepository.java @@ -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 { + + /** The alerts one case covers, oldest first - the order the pattern happened in. */ + List findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId); + + /** + * The case already covering an alert, if any. + * + *

+ * 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 findByAlert_Id(Long alertId); + + long countByStrCase_Id(Long strCaseId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java index a440216b8..ec1da1e71 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlStrCaseRepository.java @@ -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 { long countByClient_Id(Long clientId); + /** The case already open for a transaction, so escalating it again does not open a second one. */ + Optional findByAlertId(Long alertId); + List findByStatus(String status); @Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to") diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java index 19b5eae0b..e7c86daff 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRuleRepository.java @@ -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 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 findByRuleCodeIn(Collection ruleCodes); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java new file mode 100644 index 000000000..47e6f0a88 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindow.java @@ -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. + * + *

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

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

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java new file mode 100644 index 000000000..2abb34e6a --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientBehaviourWindowRepository.java @@ -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 { + + List findByClientId(Long clientId); + + /** + * Clears the windows computed for a given day before the job writes that day's. + * + *

+ * 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); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java index 4bbbdf8cc..57d48a9e2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientKyc.java @@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @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. + * + *

+ * 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 { 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 { 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; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java new file mode 100644 index 000000000..f8862414f --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameter.java @@ -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. + * + *

+ * 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; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java new file mode 100644 index 000000000..3704a0a09 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/RiskPolicyParameterRepository.java @@ -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 {} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java index 265157125..ab32ae97b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accounts/constants/ShareAccountApiConstants.java @@ -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 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java index 075e556eb..24bf24d67 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AlertReviewRequest.java @@ -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. + * + *

+ * 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 ruleCodes; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java index 498738d70..e8089278f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlAlertApiResource.java @@ -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()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java index 08289700e..3e51d8bb6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlCtrReportApiResource.java @@ -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 }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java new file mode 100644 index 000000000..bb41d1d2e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/ClientRiskChangeApiResource.java @@ -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. + * + *

+ * 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 safely(final Supplier 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; + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java index 9d921270b..cae2fb09c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlAlertData.java @@ -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 matchedRules) implements Serializable { } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java new file mode 100644 index 000000000..58faea72b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlMatchedRuleData.java @@ -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. + * + *

+ * 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 { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java index 7b2652e32..e84e938e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertReadPlatformServiceImpl.java @@ -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 retrieve(String statusFilter) { final List 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 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> findingsByAlertId = this.alertRuleRepository + .findByAlertIdIn(alerts.stream().map(AmlAlert::getId).toList()).stream() + .collect(Collectors.groupingBy(f -> f.getAlert().getId())); + final Map rulesByCode = rulesByCodeFor(alerts, findingsByAlertId); + final Map 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 riskCategoriesFor(final List alerts) { - final List 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. + * + *

+ * 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 rulesByCodeFor(final List alerts, + final Map> findingsByAlertId) { + final Set 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 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. + * + *

+ * 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 matchedRulesOf(final AmlAlert alert, final Map riskByRiskRuleId, + final Map rulesByCode, final Map> findingsByAlertId) { + final List 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 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. + * + *

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

+ * 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 riskCategoriesFor(final List alerts, final Collection alsoMatched) { + final List sourceIds = Stream + .concat(alerts.stream().map(a -> a.getRule().getSourceClientRiskRuleId()), + alsoMatched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId)) + .filter(id -> id != null).distinct().toList(); final Map 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 riskByRiskRuleId) { + private AmlAlertData mapToData(AmlAlert alert, Map riskByRiskRuleId, Map rulesByCode, + Map> 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)); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java index 80727b4cd..0eba4fbb0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformService.java @@ -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. + * + *

+ * 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 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 ruleCodes, String disposition, String notes); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java index e3ed3c725..3fe7845f0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlAlertWritePlatformServiceImpl.java @@ -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 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. + * + *

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

+ * 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 ruleCodes, final String disposition, final String notes) { + final AmlAlert alert = loadAlert(alertId); + final AmlAlertDisposition decision = AmlAlertDisposition.valueOf(disposition); + final List findings = alertRuleRepository.findByAlertId(alertId); + + final Set requested = Set.copyOf(ruleCodes); + final List 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. + * + *

+ * 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")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java index 903ab2332..0c5096eab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportGenerationService.java @@ -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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java index 6a1dfabcf..fe768bb00 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformService.java @@ -33,4 +33,13 @@ public interface AmlCtrReportReadPlatformService { * one month's report, or null for every generated month */ List retrieveLines(Long reportId); + + /** + * Reportable cash transactions that no generated report contains yet. + * + *

+ * 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 retrievePendingLines(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java index ecd0dc29e..5d95faa95 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlCtrReportReadPlatformServiceImpl.java @@ -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. + * + *

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

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

+ * 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 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 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()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java index 3dcd8ff0b..bedff9209 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java @@ -79,9 +79,11 @@ public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAle final List 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java index a0ce79771..ccf46f84a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java @@ -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. + * + *

+     *   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
+     * 
+ * + * 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 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 errors = new ArrayList<>(); final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java index 2c8023e39..60e8f940a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRuleEvaluationServiceImpl.java @@ -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. + * + *

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

+ * 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 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 raised = new ArrayList<>(); + final List 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 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 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. + * + *

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

+ * 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 severityBySourceRuleId(final List matched) { + final List sourceIds = matched.stream().map(AmlTransactionRule::getSourceClientRiskRuleId).filter(id -> id != null) + .distinct().toList(); + if (sourceIds.isEmpty()) { + return Map.of(); + } + final Map 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java new file mode 100644 index 000000000..b2ef29690 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestService.java @@ -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. + * + *

+ * 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> retrieveAll(String status); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java new file mode 100644 index 000000000..ae0d13ba5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/ClientRiskChangeRequestServiceImpl.java @@ -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 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 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> 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 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 findRequestOrThrow(final Long requestId) { + final List> 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."); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java new file mode 100644 index 000000000..ae86b6277 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientBehaviourWindowApiResource.java @@ -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 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 windows = this.readPlatformService.retrieveLatestPerClient(); + return this.toApiJsonSerializer.serialize(windows); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java new file mode 100644 index 000000000..272528b56 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientBehaviourWindowData.java @@ -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. + * + *

+ * 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 { +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java index a45882416..ac31daa26 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycApiConstants.java @@ -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. + * + *

+ * 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 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 VERIFY_REQUEST_PARAMS = new HashSet<>(Arrays.asList( diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java index 8359f4938..55dece1d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycData.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java new file mode 100644 index 000000000..3f348d999 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowJobConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java new file mode 100644 index 000000000..3d2c3e195 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/jobs/clientbehaviourwindow/ClientBehaviourWindowTasklet.java @@ -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. + * + *

+ * 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java new file mode 100644 index 000000000..b5131bde8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowReadPlatformService.java @@ -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. + * + *

+ * 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 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"))); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java new file mode 100644 index 000000000..e817e0fe3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientBehaviourWindowService.java @@ -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. + * + *

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

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

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

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

+ * 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 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(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 rows = query(AGGREGATE_SQL + " AND c.id = ? ", from, to, strBlockMonths, clientId); + return rows.isEmpty() ? null : rows.get(0); + } + + private List query(final String sql, final LocalDate from, final LocalDate to, final int strBlockMonths, + final Long clientId) { + final List 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()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java index 41ece59e2..1e8638804 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycReadPlatformServiceImpl.java @@ -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()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java new file mode 100644 index 000000000..150a5c136 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/RiskMovementPolicyService.java @@ -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. + * + *

+ * Two principles shape every rule here, and they are not symmetric. + * + *

+ * A rating never moves on one transaction. 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. + * + *

+ * Suspicion moves faster than confidence. 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 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 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 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 parameters() { + return parameterRepository.findAll().stream() + .collect(Collectors.toMap(RiskPolicyParameter::getParameterKey, RiskPolicyParameter::getParameterValue, (a, b) -> a)); + } + + /** + * A parameter's value, or the seeded default. + * + *

+ * 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 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 downgradeWindowKeys() { + return List.of("HIGH_TO_MEDIUM_WINDOW_DAYS", "MEDIUM_TO_LOW_WINDOW_DAYS"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java new file mode 100644 index 000000000..3ad4474df --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/api/ShareBorRegisterApiResource.java @@ -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. + * + *

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

+ * 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 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> rows = this.jdbcTemplate.queryForList(sql.toString(), args.toArray()); + return Response.ok(gson.toJson(rows)).build(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java index 3282fd5a9..bf3465113 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/domain/ShareAccount.java @@ -109,6 +109,18 @@ public class ShareAccount extends AbstractPersistableCustom { @Column(name = "resolution_number", nullable = false) private String resolutionNumber; + /** + * Date of the board meeting that passed the resolution above. + * + *

+ * 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 { 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())) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java index 145123138..ff3afb640 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/shareaccounts/serialization/ShareAccountDataSerializer.java @@ -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)) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 7c72ab993..464e245d9 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -85,4 +85,21 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml new file mode 100644 index 000000000..1404542fc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0546_board_resolution_register.xml @@ -0,0 +1,129 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml new file mode 100644 index 000000000..4ce02a378 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0549_aml_alert_single_row_per_transaction.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + 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. + + + + + + + + + + + + Read on every evaluation to find an alert already raised for this transaction, so a + re-run does not raise a second one. + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml new file mode 100644 index 000000000..19dbf8177 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0550_client_risk_change_request.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + The approval queue reads by status; the report reads by client. + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml new file mode 100644 index 000000000..6a00ea773 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0551_aml_rule_coverage_classification.xml @@ -0,0 +1,186 @@ + + + + + + + + + + + + + + + + + + + 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); + + + + + + + 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%'; + + + + + + + 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%'); + + + + + + + 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%'); + + + + + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml new file mode 100644 index 000000000..ef60478fc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0552_activate_automatable_aml_rules.xml @@ -0,0 +1,387 @@ + + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_DEPOSIT_DAY' + + + -- 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; + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'HIGH_VALUE_CASH_WITHOUT_PAN' + + + 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; + + + + + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml new file mode 100644 index 000000000..c6161fffd --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0553_client_behaviour_risk_review.xml @@ -0,0 +1,126 @@ + + + + + + + + + + + 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. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Read per customer, newest first, by both the report and the next run of the job. + + + + + + + + + + + The review worklist reads the customers whose rating the last window says should move. + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml new file mode 100644 index 000000000..7af939879 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_alert_highest_severity.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + 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. + + + + + + + + + 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. + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml new file mode 100644 index 000000000..85892bd79 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0554_aml_recurring_review_validity_and_tier.xml @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + UPDATE m_aml_recurring_review SET escalation_tier = 'ROUTINE' WHERE escalation_tier IS NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml new file mode 100644 index 000000000..b1197c2f1 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_aml_str_case_covered_alerts.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_aml_str_case_alert + + + 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml new file mode 100644 index 000000000..12d55fa3d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0555_low_severity_aml_rules.xml @@ -0,0 +1,109 @@ + + + + + + + + + SELECT COUNT(*) FROM m_client_risk_rule WHERE rule_kind = 'AML' AND risk_category = 1 + + + 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'); + + + + + + + SELECT COUNT(*) FROM m_aml_transaction_rule WHERE rule_code = 'MODERATE_CASH_DEPOSIT' + + + -- 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; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml new file mode 100644 index 000000000..ab0b78028 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0556_aml_alert_rule_disposition.xml @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_aml_alert_rule + + + 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 <> '' + AND trim(raw.rule_code) <> '' + ON CONFLICT (alert_id, rule_code) DO NOTHING; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml new file mode 100644 index 000000000..a115c90ef --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0557_aml_alert_raised_on_actual_time.xml @@ -0,0 +1,45 @@ + + + + + + + + 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); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml new file mode 100644 index 000000000..e0f7b40a6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0558_aml_alert_rule_directly_decided.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + UPDATE m_aml_alert_rule SET directly_decided = true WHERE disposition IS NOT NULL; + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml new file mode 100644 index 000000000..c9d19022f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0559_risk_policy_parameters.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_risk_policy_parameter + + + 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.'); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml new file mode 100644 index 000000000..1cd9467fe --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0560_client_kyc_risk_profile_fields.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml new file mode 100644 index 000000000..61b8d0400 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0561_add_client_behaviour_window_job.xml @@ -0,0 +1,60 @@ + + + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Client Behaviour Window Computation' + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml new file mode 100644 index 000000000..2d3db98d0 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0562_behaviour_window_distinct_months.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + +