diff --git a/build.gradle b/build.gradle index 6e07f078e..fc2ca9941 100644 --- a/build.gradle +++ b/build.gradle @@ -159,7 +159,7 @@ gitVersioning.apply { } ext['groovy.version'] = '4.0.17' -ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString() +ext['swaggerFile'] = "$rootDir/fineract-provider/build/resources/main/static/fineract.json".toString().replace(" ", "%20") // Apply static weaving configuration to all subprojects subprojects { subproject -> diff --git a/fineract-accounting/src/main/resources/jpa/static-weaving/module/fineract-accounting/persistence.xml b/fineract-accounting/src/main/resources/jpa/static-weaving/module/fineract-accounting/persistence.xml index 81504a3f7..7ca7674e8 100644 --- a/fineract-accounting/src/main/resources/jpa/static-weaving/module/fineract-accounting/persistence.xml +++ b/fineract-accounting/src/main/resources/jpa/static-weaving/module/fineract-accounting/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml b/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml index 9d976c68d..47da44e4b 100644 --- a/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml +++ b/fineract-branch/src/main/resources/jpa/static-weaving/module/fineract-branch/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-charge/src/main/resources/jpa/static-weaving/module/fineract-charge/persistence.xml b/fineract-charge/src/main/resources/jpa/static-weaving/module/fineract-charge/persistence.xml index fb0b9cf94..475b9f8c6 100644 --- a/fineract-charge/src/main/resources/jpa/static-weaving/module/fineract-charge/persistence.xml +++ b/fineract-charge/src/main/resources/jpa/static-weaving/module/fineract-charge/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-client/build.gradle b/fineract-client/build.gradle index 4acfe358a..6fae3000a 100644 --- a/fineract-client/build.gradle +++ b/fineract-client/build.gradle @@ -183,7 +183,7 @@ task cleanupGeneratedJavaFiles() { // Configure Java compilation tasks.named('compileJava') { outputs.cacheIf { true } - dependsOn(buildJavaSdk, buildTypescriptAngularSdk, buildAsciidoc, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply) + dependsOn(buildJavaSdk, cleanupGeneratedJavaFiles, licenseFormatMain, spotlessMiscApply) mustRunAfter(licenseFormatMain, cleanupGeneratedJavaFiles) } diff --git a/fineract-cob/src/main/resources/jpa/static-weaving/module/fineract-cob/persistence.xml b/fineract-cob/src/main/resources/jpa/static-weaving/module/fineract-cob/persistence.xml index 509a60dd9..7e5e5509f 100644 --- a/fineract-cob/src/main/resources/jpa/static-weaving/module/fineract-cob/persistence.xml +++ b/fineract-cob/src/main/resources/jpa/static-weaving/module/fineract-cob/persistence.xml @@ -53,6 +53,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType 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 ad0836c81..e9eab000b 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 @@ -36,6 +36,9 @@ public interface AmlAlertRepository extends JpaRepository { long countByClientId(Long clientId); + /** Alerts for one customer inside the recurring-behaviour observation window. */ + long countByClient_IdAndRaisedOnGreaterThanEqual(Long clientId, LocalDateTime since); + /** * Alerts already raised against one transaction under one rule. Used to stop the same ledger entry being marked * twice, which would put two STR cases in front of the Principal Officer for a single transaction. 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 new file mode 100644 index 000000000..dcab2fa94 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReview.java @@ -0,0 +1,109 @@ +/** + * 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.portfolio.client.domain.Client; +import org.apache.fineract.useradministration.domain.AppUser; + +/** + * A reviewer's judgement on a customer who repeatedly trips the AML engine. + * + * Distinct from the per-transaction disposition held on {@link AmlAlert}: that answers "was this + * transaction acceptable", this answers "is this customer's pattern of behaviour acceptable". + * {@link #alertCountAtReview} is what lets the queue clear — see the 0460 changelog. + */ +@Entity +@Table(name = "m_aml_recurring_review") +public class AmlRecurringReview { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(optional = false) + @JoinColumn(name = "client_id", nullable = false) + private Client client; + + @Column(name = "alert_count_at_review", nullable = false) + private Integer alertCountAtReview; + + @Column(name = "outcome", nullable = false, length = 30) + private String outcome; + + @Column(name = "notes", nullable = false, length = 1000) + private String notes; + + @ManyToOne(optional = false) + @JoinColumn(name = "reviewed_by", nullable = false) + private AppUser reviewedBy; + + @Column(name = "reviewed_on", nullable = false) + private LocalDateTime reviewedOn; + + protected AmlRecurringReview() {} + + public static AmlRecurringReview record(final Client client, final int alertCountAtReview, + final AmlRecurringReviewOutcome outcome, final String notes, final AppUser reviewedBy) { + final AmlRecurringReview review = new AmlRecurringReview(); + review.client = client; + review.alertCountAtReview = alertCountAtReview; + review.outcome = outcome.name(); + review.notes = notes; + review.reviewedBy = reviewedBy; + review.reviewedOn = LocalDateTime.now(); + return review; + } + + public Long getId() { + return id; + } + + public Client getClient() { + return client; + } + + public Integer getAlertCountAtReview() { + return alertCountAtReview; + } + + public AmlRecurringReviewOutcome getOutcome() { + return AmlRecurringReviewOutcome.valueOf(outcome); + } + + public String getNotes() { + return notes; + } + + public AppUser getReviewedBy() { + return reviewedBy; + } + + public LocalDateTime getReviewedOn() { + return reviewedOn; + } +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewOutcome.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewOutcome.java new file mode 100644 index 000000000..9bdd5f466 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewOutcome.java @@ -0,0 +1,30 @@ +/** + * 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; + +/** What a reviewer concluded about a customer's repeated AML activity. */ +public enum AmlRecurringReviewOutcome { + + /** Behaviour explained and acceptable — nothing further, but the customer stays visible if it recurs. */ + CLEARED, + /** Not conclusive; keep watching this customer more closely. */ + MONITOR, + /** Warrants an STR case; the reviewer escalates the individual alerts separately. */ + ESCALATED +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewRepository.java new file mode 100644 index 000000000..bff3ab4f4 --- /dev/null +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlRecurringReviewRepository.java @@ -0,0 +1,28 @@ +/** + * 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 org.springframework.data.jpa.repository.JpaRepository; + +public interface AmlRecurringReviewRepository extends JpaRepository { + + /** Newest first, so the caller can take the head as the latest review for a customer. */ + List findByClient_IdOrderByReviewedOnDesc(Long clientId); +} diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java index 44c110345..acf7ffb7a 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/aml/domain/AmlTransactionRule.java @@ -86,6 +86,29 @@ public class AmlTransactionRule { protected AmlTransactionRule() {} + /** + * Builds the live-transaction counterpart of a {@code ClientRiskRule} authored from the AML + * Rules Master screen. {@code sourceClientRiskRuleId} is the link the read side joins on, so + * the two rows are always created together and the rule code is derived from it. + */ + public static AmlTransactionRule createForRiskRule(final Long sourceClientRiskRuleId, final String description, + final AmlAppliesTo appliesTo, final AmlRuleType ruleType, final Integer windowDays, + final BigDecimal thresholdAmount, final BigDecimal minLimitAmount, final BigDecimal maxLimitAmount) { + final AmlTransactionRule rule = new AmlTransactionRule(); + rule.sourceClientRiskRuleId = sourceClientRiskRuleId; + // rule_code is UNIQUE; the source rule id is unique per row, so this cannot collide. + rule.ruleCode = "CRR-" + sourceClientRiskRuleId; + rule.description = description; + rule.appliesTo = appliesTo.name(); + rule.ruleType = ruleType.name(); + rule.windowDays = windowDays; + rule.thresholdAmount = thresholdAmount; + rule.minLimitAmount = minLimitAmount; + rule.maxLimitAmount = maxLimitAmount; + rule.active = true; + return rule; + } + public Long getId() { return id; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java index 8fb612e1b..ea246c221 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/Client.java @@ -27,6 +27,7 @@ import jakarta.persistence.JoinTable; import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; @@ -249,6 +250,9 @@ public class Client extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "is_pwd") private Boolean isPwd; + @OneToOne(mappedBy = "client", fetch = FetchType.LAZY) + private ClientKyc clientKyc; + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "pwd_category_cv_id") private CodeValue pwdCategory; 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 d3d8f9c15..4edc0de2a 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 @@ -27,6 +27,7 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; import java.time.LocalDate; +import java.math.BigDecimal; import java.util.LinkedHashMap; import java.util.Map; import org.apache.commons.lang3.StringUtils; @@ -103,6 +104,36 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "pan_status", length = 20) private String panStatus; + // ── PAN declaration ────────────────────────────────────────────────────── + // Accepted in place of a passing PAN check. The Income Tax check matches on name AND date of + // birth, so a customer whose PAN card carries a different spelling — or a different DOB — can + // never clear it however many times the officer retries. Rather than leave such a client + // permanently in KYC exception, the branch takes a signed declaration recording what differs. + // Holding that declaration is what suppresses the exception, so it is stored, not just shown. + + /** True once a signed declaration has been accepted for a PAN that could not be verified. */ + @Column(name = "pan_declaration_taken") + private Boolean panDeclarationTaken; + + /** What the declaration covers: "NAME", "DOB", "NAME,DOB", or "NONE" when PAN simply failed. */ + @Column(name = "pan_declaration_mismatch_fields", length = 100) + private String panDeclarationMismatchFields; + + /** Name exactly as printed on the physical PAN card, keyed in by the officer. */ + @Column(name = "pan_declaration_name_on_pan", length = 200) + private String panDeclarationNameOnPan; + + /** Date of birth exactly as printed on the physical PAN card. */ + @Column(name = "pan_declaration_dob_on_pan") + private LocalDate panDeclarationDobOnPan; + + /** Officer's free-text note — why the mismatch exists and what proof was sighted. */ + @Column(name = "pan_declaration_remarks", length = 500) + private String panDeclarationRemarks; + + @Column(name = "pan_declaration_taken_on") + private LocalDate panDeclarationTakenOn; + // ── Aadhaar ────────────────────────────────────────────────────────────── /** Stored masked (last 4 digits visible). Full number must never be persisted as per UIDAI directive. */ @Column(name = "aadhaar_number_masked", length = 12) @@ -114,6 +145,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "aadhaar_verified_on") private LocalDate aadhaarVerifiedOn; + // ── Aadhaar enrolment receipt ──────────────────────────────────────────── + // The counterpart of the PAN declaration, for a customer who has applied for Aadhaar but does + // not hold one yet. UIDAI issues an enrolment slip carrying a 28-digit EID, and until Aadhaar + // itself is issued there is no number to put through the OTP flow — the check cannot pass + // however long the branch waits. Recording the receipt is what waives the exception. + + /** True once the customer's Aadhaar enrolment slip has been sighted and recorded. */ + @Column(name = "aadhaar_receipt_taken") + private Boolean aadhaarReceiptTaken; + + /** 28-digit Enrolment ID from the slip, stored with its printed separators. */ + @Column(name = "aadhaar_enrolment_id", length = 40) + private String aadhaarEnrolmentId; + + /** Enrolment date printed on the slip — a receipt is temporary, so its age matters. */ + @Column(name = "aadhaar_enrolment_date") + private LocalDate aadhaarEnrolmentDate; + + /** Officer's note on what was sighted. */ + @Column(name = "aadhaar_receipt_remarks", length = 500) + private String aadhaarReceiptRemarks; + + @Column(name = "aadhaar_receipt_taken_on") + private LocalDate aadhaarReceiptTakenOn; + // ── GSTIN (mandatory for NON_INDIVIDUAL clients) ───────────────────────── @Column(name = "gstin_number", length = 15) private String gstinNumber; @@ -195,6 +251,18 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { @Column(name = "freeze_authority", length = 100) private String freezeAuthority; + @Column(name = "freeze_date") + private LocalDate freezeDate; + + @Column(name = "freeze_amount", precision = 19, scale = 6) + private BigDecimal freezeAmount; + + @Column(name = "unfreeze_date") + private LocalDate unfreezeDate; + + @Column(name = "no_debit_date") + private LocalDate noDebitDate; + // ── Form 60 / Form 15G/15H ─────────────────────────────────────────────── @Column(name = "form60_type", length = 20) private String form60Type; @@ -302,6 +370,24 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { private void applyExtendedFields(final JsonCommand command) { this.panAadhaarSeedingStatus = StringUtils.defaultIfEmpty( command.stringValueOfParameterNamed("panAadhaarSeedingStatus"), this.panAadhaarSeedingStatus); + final Boolean panDeclarationTakenVal = command.booleanObjectValueOfParameterNamed("panDeclarationTaken"); + this.panDeclarationTaken = panDeclarationTakenVal != null ? panDeclarationTakenVal : false; + this.panDeclarationMismatchFields = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("panDeclarationMismatchFields"), null); + this.panDeclarationNameOnPan = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("panDeclarationNameOnPan"), null); + this.panDeclarationDobOnPan = command.localDateValueOfParameterNamed("panDeclarationDobOnPan"); + this.panDeclarationRemarks = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("panDeclarationRemarks"), null); + this.panDeclarationTakenOn = command.localDateValueOfParameterNamed("panDeclarationTakenOn"); + final Boolean aadhaarReceiptTakenVal = command.booleanObjectValueOfParameterNamed("aadhaarReceiptTaken"); + this.aadhaarReceiptTaken = aadhaarReceiptTakenVal != null ? aadhaarReceiptTakenVal : false; + this.aadhaarEnrolmentId = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("aadhaarEnrolmentId"), null); + this.aadhaarEnrolmentDate = command.localDateValueOfParameterNamed("aadhaarEnrolmentDate"); + this.aadhaarReceiptRemarks = StringUtils.defaultIfEmpty( + command.stringValueOfParameterNamed("aadhaarReceiptRemarks"), null); + this.aadhaarReceiptTakenOn = command.localDateValueOfParameterNamed("aadhaarReceiptTakenOn"); this.enterprisePan = StringUtils.defaultIfEmpty( command.stringValueOfParameterNamed("enterprisePan"), null); this.cinNumber = StringUtils.defaultIfEmpty( @@ -331,6 +417,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { command.stringValueOfParameterNamed("freezeReason"), null); this.freezeAuthority = StringUtils.defaultIfEmpty( command.stringValueOfParameterNamed("freezeAuthority"), null); + this.freezeDate = command.localDateValueOfParameterNamed("freezeDate"); + this.freezeAmount = command.bigDecimalValueOfParameterNamed("freezeAmount"); + this.unfreezeDate = command.localDateValueOfParameterNamed("unfreezeDate"); + this.noDebitDate = command.localDateValueOfParameterNamed("noDebitDate"); this.form60Type = StringUtils.defaultIfEmpty( command.stringValueOfParameterNamed("form60Type"), null); this.form60Date = command.localDateValueOfParameterNamed("form60Date"); @@ -388,6 +478,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { updateStringField(command, "panAadhaarSeedingStatus", this.panAadhaarSeedingStatus, actualChanges, v -> this.panAadhaarSeedingStatus = v); + updateBooleanField(command, "panDeclarationTaken", this.panDeclarationTaken, actualChanges, + v -> this.panDeclarationTaken = v != null ? v : false); + updateStringField(command, "panDeclarationMismatchFields", this.panDeclarationMismatchFields, actualChanges, + v -> this.panDeclarationMismatchFields = v); + updateStringField(command, "panDeclarationNameOnPan", this.panDeclarationNameOnPan, actualChanges, + v -> this.panDeclarationNameOnPan = v); + updateDateField(command, "panDeclarationDobOnPan", this.panDeclarationDobOnPan, actualChanges, + v -> this.panDeclarationDobOnPan = v); + updateStringField(command, "panDeclarationRemarks", this.panDeclarationRemarks, actualChanges, + v -> this.panDeclarationRemarks = v); + updateDateField(command, "panDeclarationTakenOn", this.panDeclarationTakenOn, actualChanges, + v -> this.panDeclarationTakenOn = v); + updateBooleanField(command, "aadhaarReceiptTaken", this.aadhaarReceiptTaken, actualChanges, + v -> this.aadhaarReceiptTaken = v != null ? v : false); + updateStringField(command, "aadhaarEnrolmentId", this.aadhaarEnrolmentId, actualChanges, + v -> this.aadhaarEnrolmentId = v); + updateDateField(command, "aadhaarEnrolmentDate", this.aadhaarEnrolmentDate, actualChanges, + v -> this.aadhaarEnrolmentDate = v); + updateStringField(command, "aadhaarReceiptRemarks", this.aadhaarReceiptRemarks, actualChanges, + v -> this.aadhaarReceiptRemarks = v); + updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges, + v -> this.aadhaarReceiptTakenOn = 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); @@ -413,6 +525,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { updateStringField(command, "freezeReason", this.freezeReason, actualChanges, v -> this.freezeReason = v); updateStringField(command, "freezeAuthority", this.freezeAuthority, actualChanges, v -> this.freezeAuthority = v); + updateDateField(command, "freezeDate", this.freezeDate, actualChanges, v -> this.freezeDate = v); + updateBigDecimalField(command, "freezeAmount", this.freezeAmount, actualChanges, v -> this.freezeAmount = v); + updateDateField(command, "unfreezeDate", this.unfreezeDate, actualChanges, v -> this.unfreezeDate = v); + updateDateField(command, "noDebitDate", this.noDebitDate, actualChanges, v -> this.noDebitDate = v); updateStringField(command, "form60Type", this.form60Type, actualChanges, v -> this.form60Type = v); updateDateField(command, "form60Date", this.form60Date, actualChanges, v -> this.form60Date = v); updateBooleanField(command, "form15Applicable", this.form15Applicable, actualChanges, @@ -459,6 +575,15 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { } } + private void updateBigDecimalField(final JsonCommand command, final String param, final BigDecimal current, + final Map changes, final java.util.function.Consumer setter) { + if (command.isChangeInBigDecimalParameterNamed(param, current)) { + final BigDecimal newValue = command.bigDecimalValueOfParameterNamed(param); + changes.put(param, newValue); + setter.accept(newValue); + } + } + /** Called when documents are submitted and 3rd-party verification is initiated. */ public void markInProgress() { this.kycStatus = ClientKycStatus.IN_PROGRESS.getValue(); @@ -659,6 +784,60 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { return panStatus; } + public Boolean getPanDeclarationTaken() { + return panDeclarationTaken; + } + + /** Null-safe form for callers deciding whether the PAN exception is waived. */ + public boolean isPanDeclarationTaken() { + return Boolean.TRUE.equals(this.panDeclarationTaken); + } + + public String getPanDeclarationMismatchFields() { + return panDeclarationMismatchFields; + } + + public String getPanDeclarationNameOnPan() { + return panDeclarationNameOnPan; + } + + public LocalDate getPanDeclarationDobOnPan() { + return panDeclarationDobOnPan; + } + + public String getPanDeclarationRemarks() { + return panDeclarationRemarks; + } + + public LocalDate getPanDeclarationTakenOn() { + return panDeclarationTakenOn; + } + + public Boolean getAadhaarReceiptTaken() { + return aadhaarReceiptTaken; + } + + /** Null-safe form for callers deciding whether the Aadhaar exception is waived. */ + public boolean isAadhaarReceiptTaken() { + return Boolean.TRUE.equals(this.aadhaarReceiptTaken); + } + + public String getAadhaarEnrolmentId() { + return aadhaarEnrolmentId; + } + + public LocalDate getAadhaarEnrolmentDate() { + return aadhaarEnrolmentDate; + } + + public String getAadhaarReceiptRemarks() { + return aadhaarReceiptRemarks; + } + + public LocalDate getAadhaarReceiptTakenOn() { + return aadhaarReceiptTakenOn; + } + public String getAadhaarNumberMasked() { return aadhaarNumberMasked; } @@ -906,4 +1085,20 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom { public Long getVersion() { return this.version; } + + public LocalDate getFreezeDate() { + return this.freezeDate; + } + + public BigDecimal getFreezeAmount() { + return this.freezeAmount; + } + + public LocalDate getUnfreezeDate() { + return this.unfreezeDate; + } + + public LocalDate getNoDebitDate() { + return this.noDebitDate; + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java index fee2a4bf1..4f16a8700 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRepository.java @@ -63,13 +63,6 @@ public interface ClientRepository extends JpaRepository, JpaSpecif @Query("UPDATE Client client SET client.signatureId = null WHERE client.id = :clientId") void removeSignatureId(@Param("clientId") Long clientId); - /** - * Count clients whose cif_number begins with the given prefix. Used by the CIF number generator - * (TTYYYYNNNNNN format) to compute the next sequence within a given (type, year) bucket. - */ - @Query("SELECT COUNT(c) FROM Client c WHERE c.cifNumber LIKE CONCAT(:prefix, '%')") - long countByCifNumberPrefix(@Param("prefix") String prefix); - // ── Pre-save uniqueness probes ─────────────────────────────────────────── // Why: DB has UNIQUE on mobile_no / email_address, but we want to fail fast with a clear // field-level error BEFORE saveAndFlush — and importantly before the external KYC API call. diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java index b48c45040..d42bb01ab 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRule.java @@ -95,6 +95,44 @@ public class ClientRiskRule { protected ClientRiskRule() {} + /** + * Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category + * are attached separately by the caller, which owns the CodeValue lookup. + */ + public static ClientRiskRule create(final String ruleName, final String reason, final Integer riskCategory, + final Integer orderPosition, final Integer legalFormId, final BigDecimal minMonthlyIncome, + final BigDecimal maxMonthlyIncome, final BigDecimal minAnnualIncome, final BigDecimal maxAnnualIncome, + final BigDecimal minAnnualTurnover, final BigDecimal maxAnnualTurnover, final String amlCheckStatus, + final String bannedEntityMatch, final String willfulDefaulter, final String ecgcSalFlag, + final String dormantFlag) { + final ClientRiskRule rule = new ClientRiskRule(); + rule.ruleName = ruleName; + rule.reason = reason; + rule.riskCategory = riskCategory; + rule.orderPosition = orderPosition; + rule.legalFormId = legalFormId; + rule.minMonthlyIncome = minMonthlyIncome; + rule.maxMonthlyIncome = maxMonthlyIncome; + rule.minAnnualIncome = minAnnualIncome; + rule.maxAnnualIncome = maxAnnualIncome; + rule.minAnnualTurnover = minAnnualTurnover; + rule.maxAnnualTurnover = maxAnnualTurnover; + rule.amlCheckStatus = amlCheckStatus; + rule.bannedEntityMatch = bannedEntityMatch; + rule.willfulDefaulter = willfulDefaulter; + rule.ecgcSalFlag = ecgcSalFlag; + rule.dormantFlag = dormantFlag; + return rule; + } + + public void setOccupationCategory(final CodeValue occupationCategory) { + this.occupationCategory = occupationCategory; + } + + public void setOccupation(final CodeValue occupation) { + this.occupation = occupation; + } + public Long getId() { return id; } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java index 46eb97f48..d1c47a918 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/domain/ClientRiskRuleRepository.java @@ -26,4 +26,10 @@ public interface ClientRiskRuleRepository extends JpaRepository findAllOrderedByOrderPosition(); + + /** Highest position in use, so a new rule can be appended to the end of the catalog. */ + @Query("SELECT MAX(r.orderPosition) FROM ClientRiskRule r") + Integer findMaxOrderPosition(); + + boolean existsByRuleNameIgnoreCase(String ruleName); } diff --git a/fineract-core/src/main/resources/jpa/static-weaving/module/fineract-core/persistence.xml b/fineract-core/src/main/resources/jpa/static-weaving/module/fineract-core/persistence.xml index 551f6cbee..1426f81bc 100644 --- a/fineract-core/src/main/resources/jpa/static-weaving/module/fineract-core/persistence.xml +++ b/fineract-core/src/main/resources/jpa/static-weaving/module/fineract-core/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java index 6e28b5f3f..afc79f4ba 100644 --- a/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java +++ b/fineract-document/src/main/java/org/apache/fineract/infrastructure/documentmanagement/service/ImageReadPlatformServiceImpl.java @@ -18,6 +18,8 @@ */ package org.apache.fineract.infrastructure.documentmanagement.service; +import java.io.FileNotFoundException; +import java.nio.file.NoSuchFileException; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,6 +40,9 @@ import org.springframework.stereotype.Service; @Service public class ImageReadPlatformServiceImpl implements ImageReadPlatformService { + /** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */ + private static final int MAX_CAUSE_DEPTH = 10; + @Deprecated private final List imageIdAdapters; private final ContentStoreService storeService; @@ -66,7 +71,31 @@ public class ImageReadPlatformServiceImpl implements ImageReadPlatformService { } catch (final AbstractPlatformDomainRuleException e) { throw e; } catch (final Exception e) { + if (isMissingFromContentStore(e)) { + // The m_image row survives but the file it points at does not — the usual cause is a + // database copied between environments without its file store. For the caller that is + // simply "this entity has no image", not a server fault, so report it the same way as a + // missing image record instead of letting it surface as a 500. + log.warn("Image for {} {} is recorded but missing from the content store; treating it as no image", entityType, + entityId); + throw new DocumentNotFoundException(entityType, entityId, -1L); + } throw new DocumentInvalidRequestException(e); } } + + /** + * Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the + * way up (the file store rethrows as ContentStoreException). + */ + private boolean isMissingFromContentStore(final Throwable failure) { + Throwable cause = failure; + for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) { + if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) { + return true; + } + cause = cause.getCause(); + } + return false; + } } diff --git a/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml b/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml index 7c1cef3bc..98fd15012 100644 --- a/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml +++ b/fineract-investor/src/main/resources/jpa/static-weaving/module/fineract-investor/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml index 61abb20e2..ae32a1713 100644 --- a/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml +++ b/fineract-loan/src/main/resources/jpa/static-weaving/module/fineract-loan/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml b/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml index b62846adc..0449c0956 100644 --- a/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml +++ b/fineract-progressive-loan/src/main/resources/jpa/static-weaving/module/fineract-progressive-loan/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringAlertApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringAlertApiResource.java new file mode 100644 index 000000000..27bb878db --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringAlertApiResource.java @@ -0,0 +1,82 @@ +/** + * 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 io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DefaultValue; +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.core.MediaType; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData; +import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertReadPlatformService; +import org.apache.fineract.portfolio.aml.service.AmlRecurringAlertWritePlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/aml/recurring-alerts") +@Component +@Tag(name = "AML Recurring Alerts", + description = "Customers whose transactions repeatedly trip the AML engine, queued for an employee to review why.") +@RequiredArgsConstructor +public class AmlRecurringAlertApiResource { + + private final PlatformSecurityContext context; + private final AmlRecurringAlertReadPlatformService readPlatformService; + private final AmlRecurringAlertWritePlatformService writePlatformService; + private final DefaultToApiJsonSerializer toApiJsonSerializer; + + @GET + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Retrieve the recurring-alert review queue", + description = "Customers with at least minAlerts engine alerts inside the last windowDays, worst first. " + + "Each row carries the triggering transactions so the reviewer can see why the customer keeps surfacing.") + public String retrieveRecurringAlerts( + @QueryParam("minAlerts") @DefaultValue("3") final int minAlerts, + @QueryParam("windowDays") @DefaultValue("90") final int windowDays, + @QueryParam("onlyNeedingReview") @DefaultValue("false") final boolean onlyNeedingReview) { + this.context.authenticatedUser(); + final List rows = this.readPlatformService.retrieve(Math.max(minAlerts, 2), Math.max(windowDays, 1), + onlyNeedingReview); + return this.toApiJsonSerializer.serialize(rows); + } + + @POST + @Path("{clientId}/review") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record a review of a customer's repeated AML activity", + description = "outcome is CLEARED, MONITOR or ESCALATED. The customer leaves the queue until their alert " + + "count grows beyond the count reviewed here.") + public String review(@PathParam("clientId") final Long clientId, final AmlRecurringReviewRequest request) { + this.context.authenticatedUser().validateHasPermissionTo("REVIEW_AML_ALERT"); + final Long reviewId = this.writePlatformService.recordReview(clientId, request.getOutcome(), request.getNotes(), + request.getWindowDays() == null ? 0 : request.getWindowDays()); + return "{\"clientId\":" + clientId + ",\"reviewId\":" + reviewId + "}"; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringReviewRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringReviewRequest.java new file mode 100644 index 000000000..58c2bcb2f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/api/AmlRecurringReviewRequest.java @@ -0,0 +1,42 @@ +/** + * 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 io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** Request body for {@code POST /v1/aml/recurring-alerts/{clientId}/review}. */ +@Getter +@Setter +@NoArgsConstructor +public class AmlRecurringReviewRequest { + + @Schema(description = "CLEARED, MONITOR or ESCALATED", requiredMode = Schema.RequiredMode.REQUIRED, example = "MONITOR") + private String outcome; + + @Schema(description = "Why the customer keeps surfacing and what was decided", + requiredMode = Schema.RequiredMode.REQUIRED) + private String notes; + + @Schema(description = "Observation window the reviewer had on screen, so the recorded alert count matches it", + example = "90") + private Integer windowDays; +} 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 a89ead335..9d921270b 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 @@ -22,7 +22,14 @@ import java.io.Serializable; import java.math.BigDecimal; import java.time.LocalDateTime; +/** + * @param riskCategory + * severity of the rule that fired (1 Low, 2 Medium, 3 High), taken from the originating + * {@code m_client_risk_rule} entry via {@code source_client_risk_rule_id}. Null for rules with no catalog + * ancestor — MANUAL_REFERRAL is raised by a user, not derived from the rule set, so it carries no severity. + */ 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) implements Serializable { + String reviewedByUsername, LocalDateTime reviewedOn, String reviewNotes, String disposition, Integer riskCategory) + implements Serializable { } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlRecurringAlertData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlRecurringAlertData.java new file mode 100644 index 000000000..f6461e000 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/data/AmlRecurringAlertData.java @@ -0,0 +1,52 @@ +/** + * 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; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** + * One customer who has tripped the AML engine repeatedly inside the observation window. + * + * @param alertCount + * alerts in the window — what the threshold is compared against + * @param highRiskCount + * how many of those came from a High severity catalog rule + * @param distinctRules + * number of different rules involved; one rule firing five times is a weaker signal than + * five different rules firing once, and the reviewer needs to see which it is + * @param openCount + * alerts still awaiting a per-transaction disposition + * @param escalatedCount + * alerts already escalated to an STR case + * @param needsReview + * true when there is no review yet, or new alerts have arrived since the last one + * @param alerts + * the triggering transactions themselves, so the reviewer can see why this customer keeps + * surfacing without leaving the row + */ +public record AmlRecurringAlertData(Long clientId, String clientDisplayName, String accountNo, Integer clientRiskCategory, + int alertCount, int highRiskCount, int distinctRules, int openCount, int escalatedCount, BigDecimal totalAmount, + LocalDateTime firstAlertOn, LocalDateTime lastAlertOn, List ruleCodes, boolean needsReview, + Integer alertCountAtLastReview, String lastReviewOutcome, String lastReviewedByUsername, LocalDateTime lastReviewedOn, + String lastReviewNotes, LocalDate windowFrom, List alerts) 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 57b0c9bb6..7b2652e32 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 @@ -19,12 +19,15 @@ package org.apache.fineract.portfolio.aml.service; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.aml.data.AmlAlertData; import org.apache.fineract.portfolio.aml.domain.AmlAlert; import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.client.domain.ClientRiskRuleRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -34,20 +37,38 @@ import org.springframework.transaction.annotation.Transactional; public class AmlAlertReadPlatformServiceImpl implements AmlAlertReadPlatformService { private final AmlAlertRepository repository; + private final ClientRiskRuleRepository clientRiskRuleRepository; @Override public List retrieve(String statusFilter) { final List alerts = (statusFilter == null || statusFilter.isBlank()) ? repository.findAll() : repository.findByStatusOrderByRaisedOnAsc(statusFilter); - return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()).map(this::mapToData) - .collect(Collectors.toList()); + // 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); + return alerts.stream().sorted(Comparator.comparing(AmlAlert::getRaisedOn).reversed()) + .map(alert -> mapToData(alert, riskByRiskRuleId)).collect(Collectors.toList()); } - private AmlAlertData mapToData(AmlAlert alert) { + /** 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(); + final Map byId = new HashMap<>(); + if (sourceIds.isEmpty()) { + return byId; + } + this.clientRiskRuleRepository.findAllById(sourceIds).forEach(rule -> byId.put(rule.getId(), rule.getRiskCategory())); + return byId; + } + + private AmlAlertData mapToData(AmlAlert alert, Map riskByRiskRuleId) { + 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()); + alert.getReviewNotes(), alert.getDisposition() == null ? null : alert.getDisposition().name(), riskCategory); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationService.java new file mode 100644 index 000000000..0cdc878fc --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationService.java @@ -0,0 +1,33 @@ +/** + * 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 org.apache.fineract.portfolio.client.domain.Client; + +public interface AmlRecurringAlertNotificationService { + + /** + * Called once per transaction after the engine has saved its alerts. Raises a notification for + * AML reviewers when this customer's alert count crosses the recurring-behaviour threshold. + * + * @param alertsJustRaised + * how many alerts this transaction produced, so the count before it can be derived + */ + void onAlertsRaised(Client client, int alertsJustRaised); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationServiceImpl.java new file mode 100644 index 000000000..59ff80c3e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertNotificationServiceImpl.java @@ -0,0 +1,87 @@ +/** + * 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.LocalDate; +import java.time.LocalDateTime; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.notification.service.UserNotificationService; +import org.apache.fineract.portfolio.aml.domain.AmlAlertRepository; +import org.apache.fineract.portfolio.client.domain.Client; +import org.springframework.stereotype.Service; + +/** + * Pushes a notification to AML reviewers when one customer keeps tripping the engine. + * + * The review queue on the AML Rules Master screen is the durable record; this is the nudge that + * stops it having to be watched. Recipients are every user holding REVIEW_AML_ALERT in the + * customer's office, resolved by {@link UserNotificationService}. + */ +@Service +@RequiredArgsConstructor +@Slf4j +public class AmlRecurringAlertNotificationServiceImpl implements AmlRecurringAlertNotificationService { + + /** + * Matches ClientRiskEscalationServiceImpl.ESCALATION_THRESHOLD — the point the platform already + * treats as "this customer has done this enough times to warrant another look". + */ + private static final int RECURRING_THRESHOLD = 3; + private static final int WINDOW_DAYS = 90; + + private static final String REVIEW_PERMISSION = "REVIEW_AML_ALERT"; + private static final String OBJECT_TYPE = "AML_RECURRING"; + private static final String EVENT_TYPE = "recurringAmlActivity"; + + private final AmlAlertRepository alertRepository; + private final UserNotificationService userNotificationService; + + @Override + public void onAlertsRaised(final Client client, final int alertsJustRaised) { + if (client == null || alertsJustRaised <= 0) { + return; + } + try { + final LocalDateTime since = LocalDate.now().minusDays(WINDOW_DAYS).atStartOfDay(); + final long after = this.alertRepository.countByClient_IdAndRaisedOnGreaterThanEqual(client.getId(), since); + final long before = after - alertsJustRaised; + + // Fires on each multiple of the threshold - 3rd, 6th, 9th alert - rather than on every + // alert once the threshold is passed. Notifying every time would bury the reviewer in + // duplicates for a customer they have already been told about; notifying only on the + // first crossing would go permanently silent on a customer who keeps offending. + if (before / RECURRING_THRESHOLD >= after / RECURRING_THRESHOLD) { + return; + } + + final String content = String.format( + "%s has triggered %d AML alerts in the last %d days. Review why this customer keeps being flagged.", + client.getDisplayName(), after, WINDOW_DAYS); + + this.userNotificationService.notifyUsers(REVIEW_PERMISSION, OBJECT_TYPE, client.getId(), content, EVENT_TYPE, null, + client.getOffice() == null ? null : client.getOffice().getId()); + log.info("Recurring AML activity notification raised for client {} at {} alerts", client.getId(), after); + } catch (final RuntimeException e) { + // Consistent with AmlDomainServiceImpl: AML observation must never break the transaction + // it is watching, and a notification is the least important part of that observation. + log.error("Failed to raise recurring AML activity notification for client {}", client.getId(), e); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformService.java new file mode 100644 index 000000000..67ace292f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformService.java @@ -0,0 +1,36 @@ +/** + * 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 org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData; + +public interface AmlRecurringAlertReadPlatformService { + + /** + * Customers with {@code minAlerts} or more engine alerts inside the last {@code windowDays}. + * + * @param onlyNeedingReview + * when true, hides customers already reviewed at their current alert count + */ + List retrieve(int minAlerts, int windowDays, boolean onlyNeedingReview); + + /** Alerts in the window for one customer, used to record a review against the count seen. */ + int alertCountInWindow(Long clientId, int windowDays); +} 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 new file mode 100644 index 000000000..3dcd8ff0b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertReadPlatformServiceImpl.java @@ -0,0 +1,134 @@ +/** + * 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.math.BigDecimal; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.portfolio.aml.data.AmlAlertData; +import org.apache.fineract.portfolio.aml.data.AmlRecurringAlertData; +import org.apache.fineract.portfolio.aml.domain.AmlRecurringReview; +import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Builds the recurring-alert review queue. + * + * The alert set is read through {@link AmlAlertReadPlatformService} rather than queried directly, so + * the rule-severity resolution lives in exactly one place and a customer's High-risk count here can + * never disagree with the Risk Category shown on the alert itself. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AmlRecurringAlertReadPlatformServiceImpl implements AmlRecurringAlertReadPlatformService { + + private static final int HIGH_RISK = 3; + + private final AmlAlertReadPlatformService alertReadPlatformService; + private final AmlRecurringReviewRepository reviewRepository; + private final JdbcTemplate jdbcTemplate; + + @Override + public List retrieve(final int minAlerts, final int windowDays, final boolean onlyNeedingReview) { + final LocalDate windowFrom = LocalDate.now().minusDays(windowDays); + final LocalDateTime cutoff = windowFrom.atStartOfDay(); + + // Grouped in insertion order of the (already newest-first) alert list, so each customer's + // nested alerts stay newest-first without a second sort. + final Map> byClient = this.alertReadPlatformService.retrieve(null).stream() + .filter(a -> a.clientId() != null && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)) + .collect(Collectors.groupingBy(AmlAlertData::clientId, LinkedHashMap::new, Collectors.toList())); + + final List qualifying = byClient.entrySet().stream().filter(e -> e.getValue().size() >= minAlerts) + .map(Map.Entry::getKey).toList(); + if (qualifying.isEmpty()) { + return List.of(); + } + + final Map summaries = clientSummaries(qualifying); + + final List rows = new ArrayList<>(); + for (final Long clientId : qualifying) { + 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(); + if (onlyNeedingReview && !needsReview) { + continue; + } + + final ClientSummary summary = summaries.getOrDefault(clientId, new ClientSummary(null, null)); + rows.add(new AmlRecurringAlertData(clientId, alerts.get(0).clientDisplayName(), summary.accountNo(), + summary.riskCategory(), alerts.size(), + (int) alerts.stream().filter(a -> a.riskCategory() != null && a.riskCategory() == HIGH_RISK).count(), + (int) alerts.stream().map(AmlAlertData::ruleCode).distinct().count(), + (int) alerts.stream().filter(a -> "RAISED".equals(a.status()) || "UNDER_REVIEW".equals(a.status())).count(), + (int) alerts.stream().filter(a -> "ESCALATED".equals(a.status())).count(), + alerts.stream().map(AmlAlertData::amount).filter(x -> x != null).reduce(BigDecimal.ZERO, BigDecimal::add), + alerts.stream().map(AmlAlertData::raisedOn).min(Comparator.naturalOrder()).orElse(null), + alerts.stream().map(AmlAlertData::raisedOn).max(Comparator.naturalOrder()).orElse(null), + alerts.stream().map(AmlAlertData::ruleCode).distinct().sorted().toList(), needsReview, + lastReview == null ? null : lastReview.getAlertCountAtReview(), + lastReview == null ? null : lastReview.getOutcome().name(), + lastReview == null ? null : lastReview.getReviewedBy().getUsername(), + lastReview == null ? null : lastReview.getReviewedOn(), lastReview == null ? null : lastReview.getNotes(), + windowFrom, alerts)); + } + + // Worst offenders first; most recent activity breaks ties. + rows.sort(Comparator.comparingInt(AmlRecurringAlertData::alertCount).reversed() + .thenComparing(AmlRecurringAlertData::lastAlertOn, Comparator.nullsLast(Comparator.reverseOrder()))); + return rows; + } + + @Override + public int alertCountInWindow(final Long clientId, final int windowDays) { + final LocalDateTime cutoff = LocalDate.now().minusDays(windowDays).atStartOfDay(); + return (int) this.alertReadPlatformService.retrieve(null).stream() + .filter(a -> clientId.equals(a.clientId()) && a.raisedOn() != null && !a.raisedOn().isBefore(cutoff)).count(); + } + + /** Account number and current KYC risk rating, fetched once for the whole queue. */ + private Map clientSummaries(final List clientIds) { + final String placeholders = clientIds.stream().map(id -> "?").collect(Collectors.joining(",")); + final String sql = "SELECT c.id AS clientId, c.account_no AS accountNo, k.risk_category AS riskCategory " + + "FROM m_client c LEFT JOIN m_client_kyc k ON k.client_id = c.id WHERE c.id IN (" + placeholders + ")"; + final Map byId = new HashMap<>(); + this.jdbcTemplate.query(sql, rs -> { + byId.put(rs.getLong("clientId"), new ClientSummary(rs.getString("accountNo"), + rs.getObject("riskCategory") == null ? null : rs.getInt("riskCategory"))); + }, clientIds.toArray()); + return byId; + } + + private record ClientSummary(String accountNo, Integer riskCategory) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformService.java new file mode 100644 index 000000000..dbe08c823 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformService.java @@ -0,0 +1,32 @@ +/** + * 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; + +public interface AmlRecurringAlertWritePlatformService { + + /** + * Records a reviewer's judgement on a customer's repeated AML activity, stamping the alert count + * the reviewer was looking at so the customer only resurfaces when that count grows. + * + * @param windowDays + * observation window the reviewer had on screen, so the recorded count matches it + * @return the id of the stored review + */ + Long recordReview(Long clientId, String outcome, String notes, int windowDays); +} 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 new file mode 100644 index 000000000..a0ce79771 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/aml/service/AmlRecurringAlertWritePlatformServiceImpl.java @@ -0,0 +1,91 @@ +/** + * 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.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +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.AmlRecurringReview; +import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewOutcome; +import org.apache.fineract.portfolio.aml.domain.AmlRecurringReviewRepository; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientRepositoryWrapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class AmlRecurringAlertWritePlatformServiceImpl implements AmlRecurringAlertWritePlatformService { + + private static final String RESOURCE = "amlrecurringreview"; + private static final int DEFAULT_WINDOW_DAYS = 90; + + private final AmlRecurringReviewRepository reviewRepository; + private final AmlRecurringAlertReadPlatformService readPlatformService; + private final ClientRepositoryWrapper clientRepository; + private final PlatformSecurityContext context; + + @Override + @Transactional + public Long recordReview(final Long clientId, final String outcome, final String notes, final int windowDays) { + final AmlRecurringReviewOutcome parsedOutcome = validate(clientId, outcome, notes); + final int window = windowDays > 0 ? windowDays : DEFAULT_WINDOW_DAYS; + + final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId); + // Counted here rather than taken from the request: a caller-supplied count could be used to + // silence a customer at an inflated number they were never actually reviewed at. + final int alertCount = this.readPlatformService.alertCountInWindow(clientId, window); + + final AmlRecurringReview review = AmlRecurringReview.record(client, alertCount, parsedOutcome, notes.trim(), + this.context.authenticatedUser()); + return this.reviewRepository.saveAndFlush(review).getId(); + } + + 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); + + validator.reset().parameter("clientId").value(clientId).notNull(); + validator.reset().parameter("notes").value(notes).notBlank().notExceedingLengthOf(1000); + validator.reset().parameter("outcome").value(outcome).notBlank(); + + AmlRecurringReviewOutcome parsed = null; + if (StringUtils.isNotBlank(outcome)) { + for (final AmlRecurringReviewOutcome candidate : AmlRecurringReviewOutcome.values()) { + if (candidate.name().equalsIgnoreCase(outcome.trim())) { + parsed = candidate; + break; + } + } + if (parsed == null) { + validator.reset().parameter("outcome").value(outcome).failWithCode("is.not.supported"); + } + } + + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException(errors); + } + return parsed; + } +} 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 dea405fb5..862213e83 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 @@ -44,6 +44,7 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { private final AmlTransactionRuleRepository ruleRepository; private final AmlAlertRepository alertRepository; private final ClientKycRepository clientKycRepository; + private final AmlRecurringAlertNotificationService recurringAlertNotificationService; @Override public List evaluate(AmlTransactionContext context) { @@ -59,6 +60,14 @@ public class AmlRuleEvaluationServiceImpl implements AmlRuleEvaluationService { raised.add(alertRepository.save(alert)); } } + + // 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 + // re-querying what it just wrote. + if (!raised.isEmpty()) { + recurringAlertNotificationService.onAlertsRaised(context.getClient(), raised.size()); + } return raised; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java index 45abba10b..ffba5bd52 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/KycRiskRulesApiResource.java @@ -22,6 +22,7 @@ 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.Produces; import jakarta.ws.rs.core.MediaType; @@ -30,7 +31,9 @@ import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.client.data.KycRiskRuleData; +import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest; import org.apache.fineract.portfolio.client.service.KycRiskRulesReadPlatformService; +import org.apache.fineract.portfolio.client.service.KycRiskRulesWritePlatformService; import org.springframework.stereotype.Component; @Path("/v1/kyc/risk-rules") @@ -41,6 +44,7 @@ public class KycRiskRulesApiResource { private final PlatformSecurityContext context; private final KycRiskRulesReadPlatformService readPlatformService; + private final KycRiskRulesWritePlatformService writePlatformService; private final DefaultToApiJsonSerializer toApiJsonSerializer; @GET @@ -52,4 +56,17 @@ public class KycRiskRulesApiResource { final List rules = this.readPlatformService.retrieveAllRules(); return this.toApiJsonSerializer.serialize(rules); } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Add a KYC/AML risk rule", + description = "Appends a rule to the catalog. Supplying any transaction band or observation window also " + + "creates the matching live-transaction rule, linked back to the new catalog entry. Returns the " + + "persisted rule in the same shape as the catalog GET.") + public String createRiskRule(final KycRiskRuleRequest request) { + this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME); + final KycRiskRuleData rule = this.writePlatformService.createRule(request); + return this.toApiJsonSerializer.serialize(rule); + } } 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 119152bff..a45882416 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 @@ -53,6 +53,22 @@ public final class ClientKycApiConstants { // ── Extended KYC fields ────────────────────────────────────────────────── public static final String panAadhaarSeedingStatusParam = "panAadhaarSeedingStatus"; + + // ── PAN declaration (accepted when the PAN check cannot pass) ───────────── + public static final String panDeclarationTakenParam = "panDeclarationTaken"; + public static final String panDeclarationMismatchFieldsParam = "panDeclarationMismatchFields"; + public static final String panDeclarationNameOnPanParam = "panDeclarationNameOnPan"; + public static final String panDeclarationDobOnPanParam = "panDeclarationDobOnPan"; + public static final String panDeclarationRemarksParam = "panDeclarationRemarks"; + public static final String panDeclarationTakenOnParam = "panDeclarationTakenOn"; + + // ── Aadhaar enrolment receipt (accepted when Aadhaar is not yet issued) ─── + public static final String aadhaarReceiptTakenParam = "aadhaarReceiptTaken"; + public static final String aadhaarEnrolmentIdParam = "aadhaarEnrolmentId"; + public static final String aadhaarEnrolmentDateParam = "aadhaarEnrolmentDate"; + public static final String aadhaarReceiptRemarksParam = "aadhaarReceiptRemarks"; + public static final String aadhaarReceiptTakenOnParam = "aadhaarReceiptTakenOn"; + public static final String enterprisePanParam = "enterprisePan"; public static final String cinNumberParam = "cinNumber"; public static final String dinNumberParam = "dinNumber"; @@ -72,6 +88,10 @@ public final class ClientKycApiConstants { public static final String accountStatusParam = "accountStatus"; public static final String freezeReasonParam = "freezeReason"; public static final String freezeAuthorityParam = "freezeAuthority"; + public static final String freezeDateParam = "freezeDate"; + public static final String freezeAmountParam = "freezeAmount"; + public static final String unfreezeDateParam = "unfreezeDate"; + public static final String noDebitDateParam = "noDebitDate"; public static final String form60TypeParam = "form60Type"; public static final String form60DateParam = "form60Date"; public static final String form15ApplicableParam = "form15Applicable"; @@ -90,9 +110,14 @@ public final class ClientKycApiConstants { kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, + freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam, form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam, isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam, - panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam)); + panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam, + panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam, + panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam, + aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam, + aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam)); public static final Set UPDATE_REQUEST_PARAMS = new HashSet<>(Arrays.asList( kycTypeParam, panNumberParam, aadhaarNumberMaskedParam, gstinNumberParam, @@ -101,9 +126,14 @@ public final class ClientKycApiConstants { kycDocumentNameParam, kycDocumentNumberParam, kycDocIssueDateParam, kycDocExpiryDateParam, amlCheckStatusParam, bannedEntityMatchParam, willfulDefaulterParam, ecgcSalFlagParam, dormantFlagParam, accountStatusParam, freezeReasonParam, freezeAuthorityParam, + freezeDateParam, freezeAmountParam, unfreezeDateParam, noDebitDateParam, form60TypeParam, form60DateParam, form15ApplicableParam, form15TypeParam, form15FYParam, isExceptionParam, exceptionReasonParam, exceptionResolvedParam, exceptionResolvedOnParam, - panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam)); + panVerifiedParam, aadhaarVerifiedParam, gstinVerifiedParam, + panDeclarationTakenParam, panDeclarationMismatchFieldsParam, panDeclarationNameOnPanParam, + panDeclarationDobOnPanParam, panDeclarationRemarksParam, panDeclarationTakenOnParam, + aadhaarReceiptTakenParam, aadhaarEnrolmentIdParam, aadhaarEnrolmentDateParam, + aadhaarReceiptRemarksParam, aadhaarReceiptTakenOnParam)); /** 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 aa45d0ea4..76442bb39 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 @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.client.data; import java.time.LocalDate; +import java.math.BigDecimal; import lombok.Builder; import lombok.Getter; @@ -52,11 +53,29 @@ public final class ClientKycData { private final String panCategory; private final String panStatus; + // ── PAN declaration ─────────────────────────────────────────────────────── + /** Signed customer declaration accepted in place of a passing PAN check. */ + private final Boolean panDeclarationTaken; + /** "NAME", "DOB", "NAME,DOB", or "NONE" when PAN could not be checked at all. */ + private final String panDeclarationMismatchFields; + private final String panDeclarationNameOnPan; + private final LocalDate panDeclarationDobOnPan; + private final String panDeclarationRemarks; + private final LocalDate panDeclarationTakenOn; + // ── Aadhaar ─────────────────────────────────────────────────────────────── private final String aadhaarNumberMasked; private final Boolean aadhaarVerified; private final LocalDate aadhaarVerifiedOn; + // ── Aadhaar enrolment receipt ───────────────────────────────────────────── + /** Enrolment slip accepted for a customer who has applied for Aadhaar but holds none yet. */ + private final Boolean aadhaarReceiptTaken; + private final String aadhaarEnrolmentId; + private final LocalDate aadhaarEnrolmentDate; + private final String aadhaarReceiptRemarks; + private final LocalDate aadhaarReceiptTakenOn; + // ── GSTIN ───────────────────────────────────────────────────────────────── private final String gstinNumber; private final Boolean gstinVerified; @@ -65,6 +84,8 @@ public final class ClientKycData { // ── Enterprise / Non-Individual ────────────────────────────────────────── private final String enterprisePan; private final String cinNumber; + private final Boolean cinVerified; + private final LocalDate cinVerifiedOn; private final LocalDate incorporationDate; // ── KYC Document ───────────────────────────────────────────────────────── @@ -84,6 +105,10 @@ public final class ClientKycData { private final String accountStatus; private final String freezeReason; private final String freezeAuthority; + private final LocalDate freezeDate; + private final BigDecimal freezeAmount; + private final LocalDate unfreezeDate; + private final LocalDate noDebitDate; // ── Form 60 / Form 15G/15H ─────────────────────────────────────────────── private final String form60Type; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java index 7f889c835..2c1f4cf04 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/ClientKycRequest.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.client.data; import io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; import lombok.Data; /** @@ -51,6 +52,45 @@ public class ClientKycRequest { @Schema(description = "PAN-Aadhaar seeding/linking status", example = "LINKED") private String panAadhaarSeedingStatus; + @Schema(description = "True when a signed customer declaration has been accepted in place of a passing " + + "PAN check. Suppresses the automatic 'PAN Card Verification Failed' KYC exception.", example = "true") + private Boolean panDeclarationTaken; + + @Schema(description = "Which client details differ from the PAN card: NAME, DOB, NAME,DOB, or NONE when " + + "PAN could not be checked at all", example = "NAME") + private String panDeclarationMismatchFields; + + @Schema(description = "Name exactly as printed on the physical PAN card", example = "RAJESH KUMAR SHARMA") + private String panDeclarationNameOnPan; + + @Schema(description = "Date of birth exactly as printed on the physical PAN card", example = "12 May 1988") + private String panDeclarationDobOnPan; + + @Schema(description = "Officer's note on why the mismatch exists and what proof was sighted", + example = "Client's PAN carries expanded middle name; passport sighted.") + private String panDeclarationRemarks; + + @Schema(description = "Date the declaration was accepted", example = "07 August 2026") + private String panDeclarationTakenOn; + + @Schema(description = "True when the customer's Aadhaar enrolment slip has been sighted and recorded, for a " + + "customer who has applied for Aadhaar but does not hold one yet. Suppresses the automatic " + + "'Aadhaar Verification Failed' KYC exception.", example = "true") + private Boolean aadhaarReceiptTaken; + + @Schema(description = "28-digit Enrolment ID (EID) printed on the UIDAI enrolment slip", + example = "1234/12345/12345 12/05/2026 11:30:15") + private String aadhaarEnrolmentId; + + @Schema(description = "Enrolment date printed on the slip", example = "12 May 2026") + private String aadhaarEnrolmentDate; + + @Schema(description = "Officer's note on what was sighted", example = "Original UIDAI enrolment slip sighted.") + private String aadhaarReceiptRemarks; + + @Schema(description = "Date the receipt was accepted", example = "07 August 2026") + private String aadhaarReceiptTakenOn; + @Schema(description = "Enterprise PAN (for non-individual clients)", example = "ABCDE1234F") private String enterprisePan; @@ -96,6 +136,18 @@ public class ClientKycRequest { @Schema(description = "Authority that ordered the freeze (null if not frozen)") private String freezeAuthority; + @Schema(description = "Date of account freeze (null if not frozen)") + private String freezeDate; + + @Schema(description = "Amount frozen (null if not frozen)") + private BigDecimal freezeAmount; + + @Schema(description = "Date the account was unfrozen (null unless accountStatus is UNFREEZE)") + private String unfreezeDate; + + @Schema(description = "Date the no-debit restriction takes effect (null unless accountStatus is NO_DEBIT)") + private String noDebitDate; + @Schema(description = "Form 60 type (FORM_60 for clients without PAN)", example = "FORM_60") private String form60Type; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java new file mode 100644 index 000000000..5df50e4da --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/data/KycRiskRuleRequest.java @@ -0,0 +1,112 @@ +/** + * 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 io.swagger.v3.oas.annotations.media.Schema; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * Request body for {@code POST /v1/kyc/risk-rules}. + * + * Mirrors the columns the AML Rules Master screen displays. The {@code txn*} fields describe the + * live-transaction counterpart of the rule; supplying any of them creates a companion row in + * {@code m_aml_transaction_rule} linked back to the new {@code m_client_risk_rule} row. Leave them + * all blank for a pure KYC-time profile rule (income / turnover / occupation driven). + */ +@Getter +@Setter +@NoArgsConstructor +public class KycRiskRuleRequest { + + @Schema(description = "Rule description shown in the catalog", requiredMode = Schema.RequiredMode.REQUIRED) + private String ruleName; + + @Schema(description = "Reason / criteria text explaining why the rule exists", + requiredMode = Schema.RequiredMode.REQUIRED) + private String reason; + + @Schema(description = "Risk category the rule assigns: 1 Low, 2 Medium, 3 High", + requiredMode = Schema.RequiredMode.REQUIRED, example = "3") + private Integer riskCategory; + + @Schema(description = "Position in the catalog. Omit to append after the last existing rule.") + private Integer orderPosition; + + @Schema(description = "Legal form the rule is restricted to (1 Person, 2 Entity). Null applies to all.") + private Integer legalFormId; + + @Schema(description = "Occupation category code value the rule is restricted to") + private Long occupationCategoryCvId; + + @Schema(description = "Occupation code value the rule is restricted to") + private Long occupationCvId; + + private BigDecimal minMonthlyIncome; + private BigDecimal maxMonthlyIncome; + private BigDecimal minAnnualIncome; + private BigDecimal maxAnnualIncome; + private BigDecimal minAnnualTurnover; + private BigDecimal maxAnnualTurnover; + + @Schema(description = "AML check status the rule records", example = "FLAGGED") + private String amlCheckStatus; + + @Schema(description = "Banned entity match flag", example = "NO") + private String bannedEntityMatch; + + @Schema(description = "Willful defaulter flag", example = "NO") + private String willfulDefaulter; + + @Schema(description = "ECGC/SAL flag", example = "NO") + private String ecgcSalFlag; + + @Schema(description = "Dormancy flag", example = "ACTIVE") + private String dormantFlag; + + @Schema(description = "Lower bound of the transaction band the rule covers (display only)") + private BigDecimal txnMinLimitAmount; + + @Schema(description = "Upper bound of the transaction band. Null means the rule is open-ended.") + private BigDecimal txnMaxLimitAmount; + + @Schema(description = "Amount at which the rule fires on a live transaction. Defaults to the " + + "lower bound of the band when omitted.") + private BigDecimal txnThresholdAmount; + + @Schema(description = "Observation window in days. Omit or 0 for per-transaction.", example = "30") + private Integer txnWindowDays; + + @Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD") + private String txnRuleType; + + @Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL") + private String txnAppliesTo; + + /** + * True when the officer has described a live-transaction rule rather than a pure KYC profile + * rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted. + */ + public boolean hasTransactionRule() { + return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null + || (this.txnWindowDays != null && this.txnWindowDays > 0); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java index c02688dc9..28f1bb5e0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/CifNumberGenerator.java @@ -18,79 +18,42 @@ */ package org.apache.fineract.portfolio.client.service; -import java.time.LocalDate; -import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.client.domain.ClientRepository; -import org.apache.fineract.portfolio.client.domain.LegalForm; import org.springframework.stereotype.Component; -import org.apache.fineract.infrastructure.core.service.DateUtils; /** - * Generates CIF numbers in the format TTYYYYNNNNNN: - *
    - *
  • TT — Customer type code: - * 11 Individual, 22 Corporate, 33 Minor, 44 Staff. - * Priority order when multiple flags overlap: Staff > Minor > Corporate > Individual.
  • - *
  • YYYY — Year the client was submitted (4 digits).
  • - *
  • NNNNNN — Zero-padded 6-digit sequence within the (type, year) bucket.
  • - *
+ * Generates CIF numbers in the format CIF followed by the client's id + * zero-padded to 10 digits — e.g. client 123 → CIF0000000123. * - * Example: an Individual client created in 2026 → 1120260000001. - * The generator handles concurrent inserts by retrying on uniqueness collisions - * (the caller's outer save is what actually triggers the unique constraint). + *

+ * Uniqueness is structural rather than probabilistic: m_client.id is a single sequence + * shared by every office, so two clients booked concurrently at different branches can never derive + * the same CIF. That makes the number globally unique across the whole institution, not just within + * the branch that created it, and removes the read-then-write race a "count existing + 1" scheme has. + * The cif_number_unique index on m_client remains the backstop. + *

+ * + *

+ * The client must already be persisted when this is called, since the id is the source of the number. + *

*/ @Component -@RequiredArgsConstructor public class CifNumberGenerator { - private static final String TYPE_INDIVIDUAL = "11"; - private static final String TYPE_CORPORATE = "22"; - private static final String TYPE_MINOR = "33"; - private static final String TYPE_STAFF = "44"; + private static final String CIF_PREFIX = "CIF"; - private static final int SEQUENCE_WIDTH = 6; - private static final int MAX_COLLISION_RETRIES = 5; - - private final ClientRepository clientRepository; + /** 10 digits keeps the value inside m_client.cif_number's varchar(20) for any realistic id. */ + private static final int SEQUENCE_WIDTH = 10; /** - * Generate the next CIF number for the given client. Caller is responsible for persisting. + * Generate the CIF number for the given client. Caller is responsible for persisting. */ public String generate(final Client client) { - final String typeCode = resolveTypeCode(client); - final int year = resolveYear(client); - final String prefix = typeCode + year; - - long nextSequence = clientRepository.countByCifNumberPrefix(prefix) + 1; - for (int attempt = 0; attempt < MAX_COLLISION_RETRIES; attempt++) { - final String candidate = prefix + StringUtils.leftPad(Long.toString(nextSequence), SEQUENCE_WIDTH, '0'); - if (clientRepository.countByCifNumberPrefix(candidate) == 0) { - return candidate; - } - nextSequence++; + final Long clientId = client.getId(); + if (clientId == null) { + throw new IllegalArgumentException("Cannot generate a CIF number before the client has been persisted"); } - // Last resort: append client id to guarantee uniqueness if every attempt collided - return prefix + StringUtils.leftPad(Long.toString(client.getId() == null ? nextSequence : client.getId()), SEQUENCE_WIDTH, '0'); - } - - private String resolveTypeCode(final Client client) { - if (client.isStaff()) { - return TYPE_STAFF; - } - if (Boolean.TRUE.equals(client.getIsMinor())) { - return TYPE_MINOR; - } - final Integer legalForm = client.getLegalForm(); - if (legalForm != null && LegalForm.fromInt(legalForm).isEntity()) { - return TYPE_CORPORATE; - } - return TYPE_INDIVIDUAL; - } - - private int resolveYear(final Client client) { - final LocalDate submittedOn = client.getSubmittedOnDate(); - return (submittedOn != null ? submittedOn : LocalDate.now(DateUtils.getDateTimeZoneOfTenant())).getYear(); + return CIF_PREFIX + StringUtils.leftPad(Long.toString(clientId), SEQUENCE_WIDTH, '0'); } } 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 93f65baaa..4a3a3b88b 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 @@ -85,14 +85,27 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .panAadhaarSeedingStatus(kyc.getPanAadhaarSeedingStatus()) .panCategory(kyc.getPanCategory()) .panStatus(kyc.getPanStatus()) + .panDeclarationTaken(kyc.getPanDeclarationTaken()) + .panDeclarationMismatchFields(kyc.getPanDeclarationMismatchFields()) + .panDeclarationNameOnPan(kyc.getPanDeclarationNameOnPan()) + .panDeclarationDobOnPan(kyc.getPanDeclarationDobOnPan()) + .panDeclarationRemarks(kyc.getPanDeclarationRemarks()) + .panDeclarationTakenOn(kyc.getPanDeclarationTakenOn()) .aadhaarNumberMasked(kyc.getAadhaarNumberMasked()) .aadhaarVerified(kyc.isAadhaarVerified()) .aadhaarVerifiedOn(kyc.getAadhaarVerifiedOn()) + .aadhaarReceiptTaken(kyc.getAadhaarReceiptTaken()) + .aadhaarEnrolmentId(kyc.getAadhaarEnrolmentId()) + .aadhaarEnrolmentDate(kyc.getAadhaarEnrolmentDate()) + .aadhaarReceiptRemarks(kyc.getAadhaarReceiptRemarks()) + .aadhaarReceiptTakenOn(kyc.getAadhaarReceiptTakenOn()) .gstinNumber(kyc.getGstinNumber()) .gstinVerified(kyc.isGstinVerified()) .gstinVerifiedOn(kyc.getGstinVerifiedOn()) .enterprisePan(kyc.getEnterprisePan()) .cinNumber(kyc.getCinNumber()) + .cinVerified(kyc.isCinVerified()) + .cinVerifiedOn(kyc.getCinVerifiedOn()) .incorporationDate(kyc.getIncorporationDate()) .kycDocumentName(kyc.getKycDocumentName()) .kycDocumentNumber(kyc.getKycDocumentNumber()) @@ -106,6 +119,10 @@ public class ClientKycReadPlatformServiceImpl implements ClientKycReadPlatformSe .accountStatus(kyc.getAccountStatus()) .freezeReason(kyc.getFreezeReason()) .freezeAuthority(kyc.getFreezeAuthority()) + .freezeDate(kyc.getFreezeDate()) + .freezeAmount(kyc.getFreezeAmount()) + .unfreezeDate(kyc.getUnfreezeDate()) + .noDebitDate(kyc.getNoDebitDate()) .form60Type(kyc.getForm60Type()) .form60Date(kyc.getForm60Date()) .form15Applicable(kyc.getForm15Applicable()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java index bae1084f9..e46cb290c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientKycWritePlatformServiceJpaRepositoryImpl.java @@ -158,27 +158,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc final Map changes = kyc.update(command); if (!changes.isEmpty()) { - // Only changes to identifier fields invalidate prior verifications. A remarks edit or - // risk-category bump does not — keeping it as VERIFIED is the correct end state. - final boolean panChanged = changes.containsKey("panNumber"); - final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked"); - final boolean gstinChanged = changes.containsKey("gstinNumber"); - final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged; - - if (panChanged && currentStatus.isVerified()) { - kyc.resetPanVerification(); - changes.put("panVerified", false); - } - if (verificationAffected && !kyc.getKycStatus().isInProgress()) { - kyc.markInProgress(); - changes.put("kycStatus", ClientKycStatus.IN_PROGRESS.getValue()); - } - // Re-check: if the change didn't actually invalidate anything, snap back to VERIFIED. - // Handles the common case of editing remarks / risk on a fully-verified KYC. - kyc.tryAutoVerify(); - if (kyc.getKycStatus().isVerified()) { - changes.put("kycStatus", ClientKycStatus.VERIFIED.getValue()); - } kycRepositoryWrapper.saveAndFlush(kyc); } @@ -1009,23 +988,6 @@ public class ClientKycWritePlatformServiceJpaRepositoryImpl implements ClientKyc return; // no-op — edit-client sent the same KYC values that are already on the row } - // Same audit semantics as the explicit updateKyc endpoint: - // • Only identifier-field changes (PAN / Aadhaar / GSTIN) invalidate prior verifications. - // • Non-identifier edits (remarks, risk category, address) keep the row VERIFIED. - // • tryAutoVerify() at the end snaps back to VERIFIED when nothing relevant changed — - // fixes the bug where edit-client always demoted a VERIFIED KYC to IN_PROGRESS. - final boolean panChanged = changes.containsKey("panNumber"); - final boolean aadhaarChanged = changes.containsKey("aadhaarNumberMasked"); - final boolean gstinChanged = changes.containsKey("gstinNumber"); - final boolean verificationAffected = panChanged || aadhaarChanged || gstinChanged; - - if (currentStatus.isVerified() && panChanged) { - kyc.resetPanVerification(); - } - if (verificationAffected && !kyc.getKycStatus().isInProgress()) { - kyc.markInProgress(); - } - kyc.tryAutoVerify(); kycRepositoryWrapper.saveAndFlush(kyc); LOG.info("upsertKycInline: clientId={}, kycId={}, applied {} change(s), newStatus={}", client.getId(), kyc.getId(), changes.size(), kyc.getKycStatusValue()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java index 5c1183720..21dbf361b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/ClientWritePlatformServiceJpaRepositoryImpl.java @@ -619,9 +619,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP this.clientRepository.saveAndFlush(newClient); } - // CIF Number generation — TTYYYYNNNNNN format. TT picked from client type - // (44 Staff > 33 Minor > 22 Corporate > 11 Individual), YYYY from submittedOnDate, - // NNNNNN is the next 6-digit sequence within the (type, year) bucket. + // CIF Number generation — "CIF" + the client id zero-padded to 10 digits, e.g. CIF0000000123. + // Must run after the saveAndFlush above, since the id is what the number is derived from; + // deriving it from the shared id sequence is what keeps CIFs unique across all branches. if (StringUtils.isBlank(cifNumber)) { newClient.updateCifNumber(cifNumberGenerator.generate(newClient)); this.clientRepository.saveAndFlush(newClient); @@ -1466,7 +1466,9 @@ public class ClientWritePlatformServiceJpaRepositoryImpl implements ClientWriteP final Client client = this.clientRepository.findOneWithNotFoundDetection(clientId, true); validateParentGroupRulesBeforeClientActivation(client); - this.clientFamilyMembersWritePlatformService.validateFamilyRequirementsForActivation(client); + // Mother/Father-or-Caretaker is enforced where family members are captured (the Family + // Details step), not here: a client can reach approval through several different forms, + // and gating activation on it left approvers unable to approve what was handed to them. final Locale locale = command.extractLocale(); final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); final LocalDate activationDate = command.localDateValueOfParameterNamed("activationDate"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java index 8fa61d5bb..68a6ad503 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformService.java @@ -23,4 +23,7 @@ import org.apache.fineract.portfolio.client.data.KycRiskRuleData; public interface KycRiskRulesReadPlatformService { List retrieveAllRules(); + + /** One rule with its live-transaction counterpart resolved, as returned after a create. */ + KycRiskRuleData retrieveRule(Long ruleId); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java index 4e6893ab9..f896accad 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesReadPlatformServiceImpl.java @@ -48,6 +48,15 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat return rules.stream().map(rule -> mapToData(rule, txnRulesBySourceId.get(rule.getId()))).collect(Collectors.toList()); } + @Override + public KycRiskRuleData retrieveRule(final Long ruleId) { + final ClientRiskRule rule = this.repository.findById(ruleId) + .orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found")); + final AmlTransactionRule txnRule = this.amlTransactionRuleRepository.findAll().stream() + .filter(txn -> ruleId.equals(txn.getSourceClientRiskRuleId())).findFirst().orElse(null); + return mapToData(rule, txnRule); + } + private KycRiskRuleData mapToData(final ClientRiskRule rule, final AmlTransactionRule txnRule) { final Long occCatId = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getId() : null; final String occCatName = rule.getOccupationCategory() != null ? rule.getOccupationCategory().getLabel() : null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformService.java new file mode 100644 index 000000000..8a93ab551 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformService.java @@ -0,0 +1,34 @@ +/** + * 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 org.apache.fineract.portfolio.client.data.KycRiskRuleData; +import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest; + +public interface KycRiskRulesWritePlatformService { + + /** + * Adds a rule to the AML catalog. Creates one {@code m_client_risk_rule} row and, when the + * request describes a transaction band or observation window, a companion + * {@code m_aml_transaction_rule} row linked back to it. + * + * @return the persisted rule exactly as the catalog GET would return it + */ + KycRiskRuleData createRule(KycRiskRuleRequest request); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java new file mode 100644 index 000000000..9ee8786b0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/service/KycRiskRulesWritePlatformServiceImpl.java @@ -0,0 +1,162 @@ +/** + * 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.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.codes.domain.CodeValue; +import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrapper; +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.portfolio.aml.domain.AmlAppliesTo; +import org.apache.fineract.portfolio.aml.domain.AmlRuleType; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule; +import org.apache.fineract.portfolio.aml.domain.AmlTransactionRuleRepository; +import org.apache.fineract.portfolio.client.data.KycRiskRuleData; +import org.apache.fineract.portfolio.client.data.KycRiskRuleRequest; +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.Transactional; + +@Service +@RequiredArgsConstructor +public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePlatformService { + + private static final String RESOURCE = "kycriskrule"; + + private final ClientRiskRuleRepository repository; + private final AmlTransactionRuleRepository amlTransactionRuleRepository; + private final CodeValueRepositoryWrapper codeValueRepository; + private final KycRiskRulesReadPlatformService readPlatformService; + + @Override + @Transactional + public KycRiskRuleData createRule(final KycRiskRuleRequest request) { + validate(request); + + // Appending is the safe default: order_position drives evaluation precedence, so silently + // inserting into the middle of the catalog would re-prioritise existing rules. + final Integer orderPosition = request.getOrderPosition() != null ? request.getOrderPosition() : nextOrderPosition(); + + final ClientRiskRule rule = ClientRiskRule.create(request.getRuleName().trim(), request.getReason().trim(), + request.getRiskCategory(), orderPosition, request.getLegalFormId(), request.getMinMonthlyIncome(), + request.getMaxMonthlyIncome(), request.getMinAnnualIncome(), request.getMaxAnnualIncome(), + request.getMinAnnualTurnover(), request.getMaxAnnualTurnover(), + StringUtils.defaultIfBlank(request.getAmlCheckStatus(), "CLEAR"), + StringUtils.defaultIfBlank(request.getBannedEntityMatch(), "NO"), + StringUtils.defaultIfBlank(request.getWillfulDefaulter(), "NO"), + StringUtils.defaultIfBlank(request.getEcgcSalFlag(), "NO"), + StringUtils.defaultIfBlank(request.getDormantFlag(), "ACTIVE")); + + if (request.getOccupationCategoryCvId() != null) { + final CodeValue occupationCategory = this.codeValueRepository + .findOneWithNotFoundDetection(request.getOccupationCategoryCvId()); + rule.setOccupationCategory(occupationCategory); + } + if (request.getOccupationCvId() != null) { + final CodeValue occupation = this.codeValueRepository.findOneWithNotFoundDetection(request.getOccupationCvId()); + rule.setOccupation(occupation); + } + + final ClientRiskRule saved = this.repository.saveAndFlush(rule); + + if (request.hasTransactionRule()) { + this.amlTransactionRuleRepository.save(buildTransactionRule(saved, request)); + } + + return this.readPlatformService.retrieveRule(saved.getId()); + } + + private AmlTransactionRule buildTransactionRule(final ClientRiskRule saved, final KycRiskRuleRequest request) { + // The band's lower bound is what the rule fires at unless an explicit threshold is given, + // so a rule described purely as a band is still evaluable against live transactions. + final BigDecimal threshold = request.getTxnThresholdAmount() != null ? request.getTxnThresholdAmount() + : request.getTxnMinLimitAmount(); + final AmlRuleType ruleType = StringUtils.isBlank(request.getTxnRuleType()) ? AmlRuleType.THRESHOLD + : AmlRuleType.valueOf(request.getTxnRuleType().trim().toUpperCase()); + final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL + : AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase()); + + return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType, + request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount()); + } + + private Integer nextOrderPosition() { + final Integer max = this.repository.findMaxOrderPosition(); + return max == null ? 1 : max + 1; + } + + private void validate(final KycRiskRuleRequest request) { + final List errors = new ArrayList<>(); + final DataValidatorBuilder validator = new DataValidatorBuilder(errors).resource(RESOURCE); + + validator.reset().parameter("ruleName").value(request.getRuleName()).notBlank().notExceedingLengthOf(100); + validator.reset().parameter("reason").value(request.getReason()).notBlank().notExceedingLengthOf(500); + validator.reset().parameter("riskCategory").value(request.getRiskCategory()).notNull().inMinMaxRange(1, 3); + validator.reset().parameter("orderPosition").value(request.getOrderPosition()).ignoreIfNull() + .integerGreaterThanZero(); + validator.reset().parameter("txnWindowDays").value(request.getTxnWindowDays()).ignoreIfNull() + .integerZeroOrGreater(); + validator.reset().parameter("txnMinLimitAmount").value(request.getTxnMinLimitAmount()).ignoreIfNull() + .zeroOrPositiveAmount(); + validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount()).ignoreIfNull() + .zeroOrPositiveAmount(); + validator.reset().parameter("txnThresholdAmount").value(request.getTxnThresholdAmount()).ignoreIfNull() + .zeroOrPositiveAmount(); + + // A band whose ceiling sits under its floor would silently never match a transaction. + if (request.getTxnMinLimitAmount() != null && request.getTxnMaxLimitAmount() != null + && request.getTxnMaxLimitAmount().compareTo(request.getTxnMinLimitAmount()) < 0) { + validator.reset().parameter("txnMaxLimitAmount").value(request.getTxnMaxLimitAmount()) + .failWithCode("must.be.greater.than.min.limit"); + } + + if (StringUtils.isNotBlank(request.getRuleName()) && this.repository.existsByRuleNameIgnoreCase(request.getRuleName().trim())) { + validator.reset().parameter("ruleName").value(request.getRuleName()).failWithCode("duplicate"); + } + + validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class); + validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class); + + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException(errors); + } + } + + /** Guards the enum conversions in {@link #buildTransactionRule}, which would otherwise throw a + * bare IllegalArgumentException and surface to the officer as a 500. */ + private > void validateEnum(final DataValidatorBuilder validator, final String parameter, + final String value, final Class type) { + if (StringUtils.isBlank(value)) { + return; + } + final String candidate = value.trim().toUpperCase(); + for (final E constant : type.getEnumConstants()) { + if (constant.name().equals(candidate)) { + return; + } + } + validator.reset().parameter(parameter).value(value).failWithCode("is.not.supported"); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java index 1381e7ff5..2c4a309b1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/sanctions/service/SanctionsXmlLoaderService.java @@ -93,6 +93,15 @@ public class SanctionsXmlLoaderService { } } + // Replace the previous list rather than adding to it. A consolidated-list XML is a full + // snapshot, so appending would both duplicate every entry that is still listed and leave + // de-listed individuals active forever — screening has to reflect the current list only. + // Done after parsing has fully succeeded, so a malformed file cannot leave the table empty. + final long removed = sanctionsEntryRepository.countByActiveTrue(); + sanctionsEntryRepository.deleteAllByActiveTrue(); + sanctionsEntryRepository.flush(); + LOG.info("Removed {} previously loaded sanctions entries before loading the new snapshot", removed); + // Save in batches final int batchSize = 100; for (int i = 0; i < allEntries.size(); i += batchSize) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index 19fd9332a..f315eb3c2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -647,7 +647,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } private boolean isWithHoldTaxApplicableForInterestPosting(final SavingsAccountData savingsAccountData) { - return this.withHoldTax(savingsAccountData) && this.depositAccountType(savingsAccountData).isSavingsDeposit(); + return false; } private boolean withHoldTax(final SavingsAccountData savingsAccountData) { diff --git a/fineract-provider/src/main/resources/application.properties b/fineract-provider/src/main/resources/application.properties index 096798999..74ca54959 100644 --- a/fineract-provider/src/main/resources/application.properties +++ b/fineract-provider/src/main/resources/application.properties @@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost} fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432} fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres} -fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863} +fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966} fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:} fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata} fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default} @@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver} spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants} spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres} -spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863} +spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966} spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3} spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10} spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 6ea4f3c0d..ca049f3f2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -508,4 +508,11 @@ + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml index 082f60ec3..463c5e12d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0453_add_email_tracking_to_client_pending_edit.xml @@ -35,6 +35,11 @@ never exposes integer IDs. Unique constraint prevents accidental re-use. --> + + + + + + + + + + @@ -52,6 +62,11 @@ + + + + + @@ -60,6 +75,11 @@ + + + + + @@ -68,6 +88,11 @@ + + + + + @@ -76,6 +101,11 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml index 6404c3ed7..68b9571cf 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0454_add_email_from_to_client_pending_edit.xml @@ -3,6 +3,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml index e3762d6c1..b7c83dc02 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0455_add_family_member_children_table.xml @@ -3,6 +3,11 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd"> + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_add_freeze_date_and_amount_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_add_freeze_date_and_amount_to_client_kyc.xml new file mode 100644 index 000000000..6cb967346 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0456_add_freeze_date_and_amount_to_client_kyc.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_add_unfreeze_date_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_add_unfreeze_date_to_client_kyc.xml new file mode 100644 index 000000000..341a2eede --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0457_add_unfreeze_date_to_client_kyc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_add_no_debit_date_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_add_no_debit_date_to_client_kyc.xml new file mode 100644 index 000000000..fa905e99f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0458_add_no_debit_date_to_client_kyc.xml @@ -0,0 +1,16 @@ + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml new file mode 100644 index 000000000..eb2a6b00b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0459_seed_aml_alerts_awaiting_review.xml @@ -0,0 +1,72 @@ + + + + + + + + SELECT COUNT(*) FROM m_aml_alert WHERE transaction_id IN (2001, 2002) + + SELECT COUNT(*) FROM m_client WHERE external_id IN ('CLIENT_RAJESH', 'CLIENT_SONIA') + + + SELECT COUNT(*) FROM m_aml_transaction_rule + WHERE rule_code IN ('LARGE_CASH_TXN_25L', 'STRUCTURING_BELOW_10L') + + + + INSERT INTO m_aml_alert ( + transaction_type, transaction_id, client_id, rule_id, amount, currency_code, + raised_on, status, created_by, created_on_utc, last_modified_by, last_modified_on_utc, version + ) + VALUES + ( + 'SAVINGS', 2001, + (SELECT id FROM m_client WHERE external_id = 'CLIENT_RAJESH'), + (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'LARGE_CASH_TXN_25L'), + 4200000.000000, 'INR', + NOW(), 'RAISED', + 1, NOW(), 1, NOW(), 0 + ), + ( + 'SAVINGS', 2002, + (SELECT id FROM m_client WHERE external_id = 'CLIENT_SONIA'), + (SELECT id FROM m_aml_transaction_rule WHERE rule_code = 'STRUCTURING_BELOW_10L'), + 980000.000000, 'INR', + NOW(), 'RAISED', + 1, NOW(), 1, NOW(), 0 + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_aml_recurring_review.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_aml_recurring_review.xml new file mode 100644 index 000000000..a614ee7f6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0460_create_aml_recurring_review.xml @@ -0,0 +1,100 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_add_pan_declaration_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_add_pan_declaration_to_client_kyc.xml new file mode 100644 index 000000000..9bf6ede83 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0461_add_pan_declaration_to_client_kyc.xml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml new file mode 100644 index 000000000..04ffcdc00 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0462_add_aadhaar_enrolment_receipt_to_client_kyc.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml index 150a52536..aaa45249b 100644 --- a/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml +++ b/fineract-provider/src/main/resources/jpa/static-weaving/module/fineract-provider/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-rates/src/main/resources/jpa/static-weaving/module/fineract-rates/persistence.xml b/fineract-rates/src/main/resources/jpa/static-weaving/module/fineract-rates/persistence.xml index 8e0864c47..ae0949352 100644 --- a/fineract-rates/src/main/resources/jpa/static-weaving/module/fineract-rates/persistence.xml +++ b/fineract-rates/src/main/resources/jpa/static-weaving/module/fineract-rates/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-report/src/main/resources/jpa/static-weaving/module/fineract-report/persistence.xml b/fineract-report/src/main/resources/jpa/static-weaving/module/fineract-report/persistence.xml index 551f6cbee..1426f81bc 100644 --- a/fineract-report/src/main/resources/jpa/static-weaving/module/fineract-report/persistence.xml +++ b/fineract-report/src/main/resources/jpa/static-weaving/module/fineract-report/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index ad1434f36..791b19d4a 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -98,6 +98,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType; import org.apache.fineract.portfolio.charge.domain.Charge; import org.apache.fineract.portfolio.charge.exception.SavingsAccountChargeNotFoundException; import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.client.domain.ClientKyc; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; @@ -674,7 +675,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } private boolean isWithHoldTaxApplicableForInterestPosting() { - return this.withHoldTax() && this.depositAccountType().isSavingsDeposit(); + return false; } protected SavingsAccountTransaction findInterestPostingTransactionFor(final LocalDate postingDate) { @@ -715,10 +716,65 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return transaction; } + private Map calculateTaxSplit(final BigDecimal amount, final LocalDate date, final int scale) { + if (this.taxGroup == null || amount.compareTo(BigDecimal.ZERO) <= 0) { + return new HashMap<>(); + } + ClientKyc kyc = this.client != null ? this.client.getClientKyc() : null; + + // 1. Form 15G/15H Waiver Check + boolean waiveTds = false; + if (kyc != null && Boolean.TRUE.equals(kyc.getForm15Applicable())) { + String fy = kyc.getForm15Fy(); + if (isDateInFinancialYear(date, fy)) { + waiveTds = true; + } + } + + if (waiveTds) { + return new HashMap<>(); + } + + // 2. Determine if 20% higher rate applies (missing/unlinked PAN) + boolean applyHigherTdsRate = false; + if (kyc != null) { + boolean hasVerifiedPan = kyc.isPanVerified() || Boolean.TRUE.equals(kyc.getPanDeclarationTaken()); + String seedingStatus = kyc.getPanAadhaarSeedingStatus(); + + if (!hasVerifiedPan || "N".equalsIgnoreCase(seedingStatus)) { + applyHigherTdsRate = true; + } + } else { + // Default to higher rate (20%) if no KYC record is found + applyHigherTdsRate = true; + } + + // 3. Split tax + if (applyHigherTdsRate) { + return TaxUtils.splitTaxWithOverride(amount, date, this.taxGroup.getTaxGroupMappings(), scale, new BigDecimal("20.00")); + } else { + return TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), scale); + } + } + + private boolean isDateInFinancialYear(final LocalDate date, final String fy) { + if (fy == null || !fy.matches("\\d{4}-\\d{2}")) { + return false; + } + try { + int startYear = Integer.parseInt(fy.substring(0, 4)); + LocalDate start = LocalDate.of(startYear, 4, 1); + LocalDate end = LocalDate.of(startYear + 1, 3, 31); + return !date.isBefore(start) && !date.isAfter(end); + } catch (Exception e) { + return false; + } + } + protected boolean createWithHoldTransaction(final BigDecimal amount, final LocalDate date, final boolean backdatedTxnsAllowedTill) { boolean isTaxAdded = false; if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = TaxUtils.splitTax(amount, date, this.taxGroup.getTaxGroupMappings(), amount.scale()); + Map taxSplit = calculateTaxSplit(amount, date, amount.scale()); BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); if (totalTax.compareTo(BigDecimal.ZERO) > 0) { SavingsAccountTransaction withholdTransaction = SavingsAccountTransaction.withHoldTax(this, office(), date, @@ -737,8 +793,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom protected boolean updateWithHoldTransaction(final BigDecimal amount, final SavingsAccountTransaction withholdTransaction) { boolean isTaxAdded = false; if (this.taxGroup != null && amount.compareTo(BigDecimal.ZERO) > 0) { - Map taxSplit = TaxUtils.splitTax(amount, withholdTransaction.getTransactionDate(), - this.taxGroup.getTaxGroupMappings(), amount.scale()); + Map taxSplit = calculateTaxSplit(amount, withholdTransaction.getTransactionDate(), amount.scale()); BigDecimal totalTax = TaxUtils.totalTaxAmount(taxSplit); if (totalTax.compareTo(BigDecimal.ZERO) > 0) { if (withholdTransaction.getId() == null) { diff --git a/fineract-savings/src/main/resources/jpa/static-weaving/module/fineract-savings/persistence.xml b/fineract-savings/src/main/resources/jpa/static-weaving/module/fineract-savings/persistence.xml index 4e9ab642e..591f86112 100644 --- a/fineract-savings/src/main/resources/jpa/static-weaving/module/fineract-savings/persistence.xml +++ b/fineract-savings/src/main/resources/jpa/static-weaving/module/fineract-savings/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType diff --git a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/TaxUtils.java b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/TaxUtils.java index ee5b0cd10..f25c0b144 100644 --- a/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/TaxUtils.java +++ b/fineract-tax/src/main/java/org/apache/fineract/portfolio/tax/service/TaxUtils.java @@ -126,4 +126,32 @@ public final class TaxUtils { } return totalAmount; } + + public static Map splitTaxWithOverride(final BigDecimal amount, final LocalDate date, + final Set taxGroupMappings, final int scale, final BigDecimal overridePercentage) { + Map map = new HashMap<>(3); + if (amount != null && overridePercentage != null) { + final double amountVal = amount.doubleValue(); + double cent_percentage = Double.parseDouble("100.0"); + boolean overridden = false; + for (TaxGroupMappings groupMappings : taxGroupMappings) { + if (groupMappings.occursOnDayFromAndUpToAndIncluding(date)) { + TaxComponent component = groupMappings.getTaxComponent(); + BigDecimal percentage = overridden ? BigDecimal.ZERO : overridePercentage; + double percentageVal = percentage.doubleValue(); + double tax = amountVal * percentageVal / cent_percentage; + map.put(component, BigDecimal.valueOf(tax).setScale(scale, MoneyHelper.getRoundingMode())); + overridden = true; + } + } + if (!overridden && !taxGroupMappings.isEmpty()) { + TaxGroupMappings firstMapping = taxGroupMappings.iterator().next(); + TaxComponent component = firstMapping.getTaxComponent(); + double tax = amountVal * overridePercentage.doubleValue() / cent_percentage; + map.put(component, BigDecimal.valueOf(tax).setScale(scale, MoneyHelper.getRoundingMode())); + } + } + return map; + } } + diff --git a/fineract-tax/src/main/resources/jpa/static-weaving/module/fineract-tax/persistence.xml b/fineract-tax/src/main/resources/jpa/static-weaving/module/fineract-tax/persistence.xml index daaf89b7b..1567d37bd 100644 --- a/fineract-tax/src/main/resources/jpa/static-weaving/module/fineract-tax/persistence.xml +++ b/fineract-tax/src/main/resources/jpa/static-weaving/module/fineract-tax/persistence.xml @@ -54,6 +54,7 @@ org.apache.fineract.portfolio.group.domain.StaffAssignmentHistory org.apache.fineract.portfolio.group.domain.Group org.apache.fineract.portfolio.client.domain.Client + org.apache.fineract.portfolio.client.domain.ClientKyc org.apache.fineract.infrastructure.event.external.repository.domain.ExternalEventConfiguration org.apache.fineract.portfolio.group.domain.GroupRole org.apache.fineract.portfolio.paymenttype.domain.PaymentType