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 bd2bd1017..0e33ce55b 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,6 +508,11 @@
+
@@ -515,4 +520,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