Add CC accounts tracking, AML rule trial and branch approval fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-10-19 18:10:52 +05:30
parent e4a2c00d75
commit ae69d126a9
108 changed files with 7866 additions and 199 deletions
@@ -42,6 +42,8 @@ public enum JobName {
POST_SHARE_ENTRY_FEE_TO_PROFIT("Post Share Entry Fee To Profit"), //
UPDATE_SAVINGS_DORMANT_ACCOUNTS("Update Savings Dormant Accounts"), //
APPLY_KYC_EXPIRY_RESTRICTIONS("Apply KYC Expiry Restrictions"), //
SEND_KYC_REMINDERS("Send KYC Reminders"), //
PROCESS_KYC_NOTICE_QUEUE("Process KYC Notice Queue"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
SAVINGS_INTEREST_POSTING_REPORT("Generate Savings Interest Posting Report"), //
FD_INTEREST_POSTING_REPORT("Generate Fixed Deposit Interest Posting Report"), //
@@ -83,15 +83,24 @@ public class SavingsAccountSummaryData {
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
/**
* The branch this account is held at: the account's own office when it was opened at another branch than the
* client's, else the client's (or the group's) office. Lets a customer with accounts at several branches tell
* them apart on the client page.
*/
private final Long officeId;
private final String officeName;
public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId,
final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency,
final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold,
final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline,
final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
final String fdAccountNumber, final Integer renewalCycleNo, final Long officeId, final String officeName) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.officeId = officeId;
this.officeName = officeName;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -21,7 +21,11 @@ package org.apache.fineract.portfolio.aml.domain;
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
public enum AmlAppliesTo {
SAVINGS, LOAN, TRANSFER, ALL;
/**
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
* for it is catalogued but does not fire until it is.
*/
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
public boolean matches(AmlTransactionType transactionType) {
return this == ALL || this.name().equals(transactionType.name());
@@ -20,14 +20,12 @@ 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.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -40,11 +38,7 @@ import org.apache.fineract.useradministration.domain.AppUser;
*/
@Entity
@Table(name = "m_aml_recurring_review")
public class AmlRecurringReview {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
public class AmlRecurringReview extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne(optional = false)
@JoinColumn(name = "client_id", nullable = false)
@@ -107,10 +101,6 @@ public class AmlRecurringReview {
this.alertsActioned = count;
}
public Long getId() {
return id;
}
public Client getClient() {
return client;
}
@@ -152,6 +152,15 @@ public class AmlTransactionRule {
return rule;
}
/**
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
*/
public void restrictTo(final String direction, final boolean cashOnly) {
this.direction = direction;
this.cashOnly = cashOnly;
}
public Long getId() {
return id;
}
@@ -0,0 +1,61 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.util.regex.Pattern;
/**
* The comparable fingerprint of an Aadhaar number: the SHA-256 of the 12 digits, as 64 lowercase hex characters.
*
* <p>
* The hash is computed by the client (browser) and only the hash is sent. The KYC create/update calls go through the
* command source, which records the request body in an audit table and in the log, so the 12-digit number must not be
* in the body. The server therefore never sees the number and only checks that what it was given has the shape of a
* SHA-256 digest.
*
* <p>
* A plain, unkeyed SHA-256 of a 12-digit number can be reversed by trying every possible number, so this column is a
* lookup key, not protection against someone who can read the table.
*/
public final class AadhaarHash {
private static final Pattern SHA256_HEX = Pattern.compile("^[0-9a-f]{64}$");
private AadhaarHash() {}
/** True when the value has the shape of a lowercase hex SHA-256 digest. */
public static boolean isValid(final String value) {
return value != null && SHA256_HEX.matcher(value).matches();
}
/**
* The value trimmed and lower-cased when it is a well-formed digest; null for blank input. A non-blank value that
* is not a digest is rejected rather than silently dropped, so a client bug cannot disable the duplicate check.
*/
public static String normalizeOrNull(final String raw) {
if (raw == null || raw.isBlank()) {
return null;
}
final String candidate = raw.trim().toLowerCase();
if (!isValid(candidate)) {
throw new IllegalArgumentException("aadhaarHash must be a 64-character hex SHA-256 digest");
}
return candidate;
}
}
@@ -151,6 +151,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "aadhaar_number_masked", length = 12)
private String aadhaarNumberMasked;
/**
* SHA-256 of the 12-digit Aadhaar, computed client-side (see {@link AadhaarHash}). The masked form cannot tell two
* people apart; this can. Null for rows that predate the column and for clients with no Aadhaar.
*/
@Column(name = "aadhaar_hash", length = 64)
private String aadhaarHash;
@Column(name = "aadhaar_verified", nullable = false)
private boolean aadhaarVerified;
@@ -391,6 +398,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
command.stringValueOfParameterNamed("aadhaarNumberMasked"),
command.stringValueOfParameterNamed("gstinNumber"),
command.stringValueOfParameterNamed("remarks"));
kyc.aadhaarHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed("aadhaarHash"));
kyc.applyExtendedFields(command);
return kyc;
}
@@ -493,6 +501,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(aadhaarParam, newValue);
recordOverwrite(aadhaarParam, this.aadhaarNumberMasked);
this.aadhaarNumberMasked = StringUtils.defaultIfEmpty(newValue, null);
// The Aadhaar was removed: the fingerprint of the old number must not outlive it.
if (this.aadhaarNumberMasked == null) {
this.aadhaarHash = null;
}
}
// Only the fingerprint is sent, never the number. Reported as a flag, not a value, because the changes map
// is echoed back in the API response.
final String aadhaarHashParam = "aadhaarHash";
if (command.parameterExists(aadhaarHashParam)) {
final String newHash = AadhaarHash.normalizeOrNull(command.stringValueOfParameterNamed(aadhaarHashParam));
if (newHash != null && !newHash.equals(this.aadhaarHash)) {
this.aadhaarHash = newHash;
actualChanges.put("aadhaarIdentityUpdated", true);
}
}
final String gstinParam = "gstinNumber";
@@ -1050,6 +1073,10 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return aadhaarNumberMasked;
}
public String getAadhaarHash() {
return aadhaarHash;
}
public boolean isAadhaarVerified() {
return aadhaarVerified;
}
@@ -27,6 +27,13 @@ public interface ClientKycContactLogRepository extends JpaRepository<ClientKycCo
List<ClientKycContactLog> findByClientIdOrderByContactedAtAsc(Long clientId);
/** True when a notice carrying this marker has already reached a final outcome on the channel. */
boolean existsByClientIdAndChannelAndNotesStartingWithAndDeliveryStatusIn(Long clientId, KycContactChannel channel,
String markerPrefix, java.util.Collection<KycContactDeliveryStatus> statuses);
/** How many times a marked notice has been tried on the channel — bounds the retries of a failing gateway. */
long countByClientIdAndChannelAndNotesStartingWith(Long clientId, KycContactChannel channel, String markerPrefix);
/**
* Every contact log for the given clients in one round trip, so the exceptions grid can render
* "last contacted" for the whole page without an N+1 query per row.
@@ -61,6 +61,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @Param("rejectedStatus") Integer rejectedStatus);
/**
* KYC records whose re-KYC falls due after {@code asOn} but no later than {@code until}, soonest first.
*
* <p>The complement of {@link #findLapsedAsOn}: a record is in one list or the other, never both, so the
* two together cover everything that needs the branch's attention. Rejected records are excluded for
* the same reason as there.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate > :asOn "
+ "AND k.expiryDate <= :until AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findExpiringBetween(@Param("asOn") LocalDate asOn, @Param("until") LocalDate until,
@Param("rejectedStatus") Integer rejectedStatus);
// ── Pre-KYC uniqueness probes ───────────────────────────────────────────
// Why: stop a second client from being created with the same PAN / Aadhaar / GSTIN BEFORE the
// external KYC API is called, so we don't burn vendor calls on duplicate-identity records. PAN
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
@Entity
@@ -110,11 +111,57 @@ public class ClientRiskRule {
@Column(name = "rule_kind", length = 10)
private String ruleKind;
/**
* Whether this scenario applies to Cash Credit accounts - a current account that is allowed to draw an overdraft
* against a sanctioned limit. Lets the Rules Master show, in one place, every scenario that watches a CC account,
* whatever its coverage status.
*/
@Column(name = "cc_relevant", nullable = false)
private boolean ccRelevant;
/**
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
* normal. Every rule that was already in the catalog is normal.
*/
@Column(name = "on_trial", nullable = false)
private boolean onTrial;
@Column(name = "trial_started_on")
private LocalDate trialStartedOn;
public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML";
protected ClientRiskRule() {}
public boolean isCcRelevant() {
return ccRelevant;
}
public boolean isOnTrial() {
return onTrial;
}
public LocalDate getTrialStartedOn() {
return trialStartedOn;
}
/** Puts a newly authored rule on trial from {@code startedOn}. */
public void startTrial(final LocalDate startedOn) {
this.onTrial = true;
this.trialStartedOn = startedOn;
}
/** Ends the trial: the rule is a normal part of the catalog. */
public void markNormal() {
this.onTrial = false;
}
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
public void markCcRelevant(final boolean ccRelevant) {
this.ccRelevant = ccRelevant;
}
/**
* 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.
@@ -26,7 +26,8 @@ public interface SearchingClientRepository {
/**
* @param legalFormValue null = no filter; 1 = Individual only; 2 = Non-Individual only.
* Driven by the legal-form enum on Client (LegalForm.PERSON / .ENTITY).
* @param officeId null = no filter; otherwise only clients of that office, still inside officeHierarchy.
*/
Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue);
Integer legalFormValue, Long officeId);
}
@@ -53,7 +53,7 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
@Override
public Page<SearchedClient> searchByText(String searchText, Pageable pageable, String officeHierarchy, boolean activeOnly,
Integer legalFormValue) {
Integer legalFormValue, Long officeId) {
/*
* this whole thing can be replaced with Spring Data JPA 3+ with a findBy(Specification, Pageable) call but at
* this point the upgrade is too costly
@@ -62,6 +62,9 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
*/
String hierarchyLikeValue = officeHierarchy + "%";
// Customers of other branches that hold an account at the caller's branch are listed here too.
final List<Long> linkedClientIds = clientIdsWithAccountAtBranch(hierarchyLikeValue);
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<SearchedClient> query = cb.createQuery(SearchedClient.class);
@@ -74,7 +77,8 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
Join<Client, ClientIdentifier> identity = r.join("identifiers", JoinType.LEFT);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.like(o.get("hierarchy"), hierarchyLikeValue));
final Predicate inHierarchy = cb.like(o.get("hierarchy"), hierarchyLikeValue);
predicates.add(linkedClientIds.isEmpty() ? inHierarchy : cb.or(inHierarchy, r.get("id").in(linkedClientIds)));
String searchLikeValue = "%" + searchText + "%";
predicates.add(cb.or(cb.like(r.get("accountNumber"), searchLikeValue), cb.like(r.get("cifNumber"), searchLikeValue),
@@ -93,6 +97,11 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
predicates.add(cb.equal(r.get("legalForm"), legalFormValue));
}
// Branch filter. ANDed with the hierarchy predicate above, so it can only narrow results.
if (officeId != null) {
predicates.add(cb.equal(o.get("id"), officeId));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
criteriaQueryFactory.applySpecificationToCriteria(root, spec, query);
@@ -108,4 +117,27 @@ public class SearchingClientRepositoryImpl implements SearchingClientRepository
return criteriaQueryFactory.readPage(queryToExecute, Client.class, pageable, spec);
}
/**
* Ids of clients holding an account (savings, fixed or recurring deposit, or current) at an office at or below the
* given hierarchy pattern, whatever office the client itself belongs to. Only accounts opened at a branch other than
* the client's carry their own office, so this is empty for a branch with no such customers, and for Head Office.
* Being linked to a branch is deliberately not enough to be listed there.
*/
@SuppressWarnings("unchecked")
private List<Long> clientIdsWithAccountAtBranch(final String hierarchyLikeValue) {
final List<Object> rows = entityManager.createNativeQuery(
"SELECT sa.client_id FROM m_savings_account sa JOIN m_office ao ON ao.id = sa.office_id "
+ "WHERE sa.client_id IS NOT NULL AND ao.hierarchy LIKE ?1 "
+ "UNION SELECT ca.client_id FROM m_current_account ca JOIN m_office co ON co.id = ca.office_id "
+ "WHERE ca.client_id IS NOT NULL AND co.hierarchy LIKE ?2")
// Positional, not named: EclipseLink passes a native query's ":h" to PostgreSQL as written, which fails
// with a syntax error and took the whole client list down with it.
.setParameter(1, hierarchyLikeValue).setParameter(2, hierarchyLikeValue).getResultList();
final List<Long> ids = new ArrayList<>(rows.size());
for (final Object row : rows) {
ids.add(((Number) row).longValue());
}
return ids;
}
}
@@ -32,6 +32,7 @@ public enum NoteType {
GROUP(600, "noteType.group", "groups", "Group note"), //
SHARE_ACCOUNT(700, "noteType.shares", "accounts/share", "Share account note"), //
SAVINGS_TRANSACTION(800, "noteType.savings.transaction", "savingsTransactions", "Savings transaction note"), //
CURRENT_ACCOUNT(900, "noteType.currentaccount", "currentaccounts", "Current account note"), //
;
public static final NoteType[] VALUES = values();