Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports

# Conflicts:
#	fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java
#	fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java
#	fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml
This commit is contained in:
tarunmalviya
2026-09-23 10:23:37 +05:30
507 changed files with 58374 additions and 8871 deletions
@@ -269,7 +269,15 @@ public final class AccountingConstants {
OVERDRAFT_PORTFOLIO_CONTROL(11), //
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14); //
ESCHEAT_LIABILITY(14), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
* Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as
* 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered
* well clear of the accrual slots so the two sets never collide as either grows.
*/
ROUNDING_ADJUSTMENT(26); //
private final Integer value;
@@ -317,7 +325,9 @@ public final class AccountingConstants {
FEES_RECEIVABLE(15), //
PENALTIES_RECEIVABLE(16), //
INTEREST_PAYABLE(17), //
INTEREST_RECEIVABLE(18);
INTEREST_RECEIVABLE(18), //
/** Where the paise dropped from a cash closure are absorbed — see the cash enum. */
ROUNDING_ADJUSTMENT(26);
private final Integer value;
@@ -372,7 +382,8 @@ public final class AccountingConstants {
PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), //
INTEREST_RECEIVABLE("interestReceivableAccountId"), //
FEES_RECEIVABLE("feesReceivableAccountId"), //
INTEREST_PAYABLE("interestPayableAccountId");
INTEREST_PAYABLE("interestPayableAccountId"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId");
private final String value;
@@ -410,7 +421,8 @@ public final class AccountingConstants {
FEES_RECEIVABLE("feeReceivableAccount"), //
PENALTIES_RECEIVABLE("penaltyReceivableAccount"), //
INTEREST_PAYABLE("interestPayableAccount"), //
INTEREST_RECEIVABLE("interestReceivableAccount"); //
INTEREST_RECEIVABLE("interestReceivableAccount"), //
ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); //
private final String value;
@@ -437,8 +449,34 @@ public final class AccountingConstants {
OPENING_BALANCES_TRANSFER_CONTRA(300, "openingBalancesTransferContra", GLAccountType.EQUITY), //
ASSET_FUND_SOURCE(103, "fundSource", GLAccountType.ASSET), //
PAYABLE_DIVIDENDS(201, "payableDividends", GLAccountType.LIABILITY), //
DIVIDEND_PAID(202, "dividendPaid", GLAccountType.LIABILITY), //
RESERVE_FOR_DIVIDEND(301, "reserveForDividend", GLAccountType.EQUITY), //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME); //
DIVIDEND_FORFEITED_INCOME(400, "dividendForfeitedIncome", GLAccountType.INCOME), //
/**
* The year's closed-out profit a dividend is declared against.
*
* Kept apart from {@link #RETAINED_PROFIT} on purpose: a declaration is sized on this balance, so anything that
* has already been through a distribution decision must not sit here, or the same money would be offered for
* distribution a second time.
*/
DISTRIBUTABLE_PROFIT(302, "distributableProfit", GLAccountType.EQUITY), //
/**
* Where profit settles once it is no longer distributable - the remainder left after an approved declaration
* and dividends forfeited after the three-year claim window. Nothing here ever enters a later dividend
* calculation.
*/
RETAINED_PROFIT(303, "retainedProfit", GLAccountType.EQUITY), //
/**
* Where the paise a dividend cannot divide evenly are collected.
*
* A declaration of 1,538.91 that allocates 1,538 to members leaves 0.91 that belongs to nobody. It used to be
* swept into retained profit along with the undistributed remainder, which buried an arithmetic artefact inside
* a figure the society reads as a real business result. Given its own account it stays separately readable -
* and an auditor asking why members received less than was declared can be answered from the ledger.
*
* Optional: a society that has not mapped it has the residue swept to retained profit exactly as before.
*/
DIVIDEND_ROUND_OFF(304, "dividendRoundOff", GLAccountType.EQUITY); //
private final Integer value;
private final String code;
@@ -137,7 +137,7 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || user.isCheckerSuperUser()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,6 +151,25 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -817,6 +817,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder deleteOffice(final Long officeId) {
this.actionName = "DELETE";
this.entityName = "OFFICE";
this.entityId = officeId;
this.href = "/offices/" + officeId;
return this;
}
public CommandWrapperBuilder createOfficeTransaction() {
this.actionName = "CREATE";
this.entityName = "OFFICETRANSACTION";
@@ -2364,6 +2372,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSBASEINTERESTRATE";
this.entityId = null;
this.href = "/fixeddepositproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -3050,6 +3066,15 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) {
this.actionName = "PAYMENT";
this.entityName = "FIXEDDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/fixeddepositaccounts/" + accountId + "?command=payment";
return this;
}
public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) {
this.actionName = "CALCULATEINTEREST";
this.entityName = "FIXEDDEPOSITACCOUNT";
@@ -3256,6 +3281,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder updateFacilityChargeMapping(final Long mappingId) {
this.actionName = "UPDATE";
this.entityName = "FACILITYCHARGEMAPPING";
this.entityId = mappingId;
this.href = "/facilitychargemappings/" + mappingId;
return this;
}
public CommandWrapperBuilder deleteOfficeToGLAccountMapping(final Long mappingId) {
this.actionName = "DELETE";
this.entityName = "FINANCIALACTIVITYACCOUNT";
@@ -26,6 +26,8 @@ public interface ConfigurationDomainService {
boolean isMakerCheckerEnabledForTask(String taskPermissionCode);
boolean isMakerCheckerEnabled();
/**
* Global kill-switch for MFA-factor-backed login enforcement. When true, users with at least
* one verified, non-disabled row in {@code m_user_mfa_factor} must complete a 2FA challenge
@@ -130,6 +130,19 @@ public class DataValidatorBuilder {
this.dataValidationErrors.add(error);
}
/**
* Like {@link #failWithCode(String, Object...)}, but the caller's own message is what's actually shown to the
* user (that method always renders the generic "Failed data validation due to: {errorCode}." regardless of the
* args passed to it).
*/
public void failWithCodeAndMessage(final String errorCode, final String defaultUserMessage,
final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + this.parameter + "." + errorCode;
final ApiParameterError error = ApiParameterError.parameterError(validationErrorCode, defaultUserMessage, this.parameter,
this.value, defaultUserMessageArgs);
this.dataValidationErrors.add(error);
}
public void failWithCodeNoParameterAddedToErrorCode(final String errorCode, final Object... defaultUserMessageArgs) {
String validationErrorCode = "validation.msg." + this.resource + "." + errorCode;
String defaultEnglishMessage = "Failed data validation due to: " + errorCode + ".";
@@ -39,7 +39,10 @@ public enum JobName {
GENERATE_LOANLOSS_PROVISIONING("Generate Loan Loss Provisioning"), //
POST_DIVIDENTS_FOR_SHARES("Post Dividends For Shares"), //
FORFEIT_UNCLAIMED_DIVIDENDS("Forfeit Unclaimed Dividends"), //
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"), //
SAVINGS_DAILY_INTEREST("Savings Daily Interest Accrual And Posting"), //
ADD_PERIODIC_ACCRUAL_ENTRIES_FOR_LOANS_WITH_INCOME_POSTED_AS_TRANSACTIONS(
"Add Accrual Transactions For Loans With Income Posted As Transactions"), //
EXECUTE_REPORT_MAILING_JOBS("Execute Report Mailing Jobs"), //
@@ -53,6 +56,7 @@ public enum JobName {
EXECUTE_DIRTY_JOBS("Execute All Dirty Jobs"), //
INCREASE_BUSINESS_DATE_BY_1_DAY("Increase Business Date by 1 day"), //
INCREASE_COB_DATE_BY_1_DAY("Increase COB Date by 1 day"), //
SYNC_BUSINESS_DATE_TO_SYSTEM_DATE("Sync Business Date To System Date"), //
LOAN_COB("Loan COB"), //
LOAN_DELINQUENCY_CLASSIFICATION("Loan Delinquency Classification"), //
SEND_ASYNCHRONOUS_EVENTS("Send Asynchronous Events"), //
@@ -69,6 +73,7 @@ public enum JobName {
CTR_REPORT_GENERATION("AML Monthly CTR Report Generation"), //
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
POST_DDS_INTEREST("Post DDS Interest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
; //
private final String name;
@@ -61,4 +61,15 @@ public class OfficeRepositoryWrapper {
public void delete(final Office entity) {
this.repository.delete(entity);
}
/**
* Deletes and flushes in the same call, so a foreign-key violation the caller did not already check
* for (a savings transaction, a GL journal entry, a teller position -- anything on m_office's long
* tail of referencing tables) surfaces synchronously here, inside the caller's own try/catch, rather
* than at transaction-commit time where it can no longer be turned into a clean error message.
*/
public void deleteAndFlush(final Office entity) {
this.repository.delete(entity);
this.repository.flush();
}
}
@@ -72,11 +72,26 @@ public class SavingsAccountSummaryData {
// differentiate deposit accounts Savings, FD and RD accounts
private final EnumOptionData depositType;
/**
* For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal
* chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo
* for an FD that has never been renewed, and is null for non-deposit accounts.
*/
private final String fdAccountNumber;
/**
* Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so
* on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately.
*/
private final Integer renewalCycleNo;
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 EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate,
final String fdAccountNumber, final Integer renewalCycleNo) {
this.fdAccountNumber = fdAccountNumber;
this.renewalCycleNo = renewalCycleNo;
this.id = id;
this.accountNo = accountNo;
this.externalId = externalId;
@@ -78,6 +78,30 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "disposition", length = 20)
private String disposition;
/**
* The other rules this same transaction matched, comma-separated.
*
* <p>
* One transaction raises one alert, under the rule that most warrants attention - otherwise a
* single deposit arrives as several alerts and an officer has to dispose of each, with nothing
* stopping them clearing one while escalating another. The rules that did not win are kept here
* so the case file still shows everything that fired.
*/
@Column(name = "also_matched_rules", length = 500)
private String alsoMatchedRules;
/**
* The worst severity band across every rule this transaction matched — 1 Low, 2 Medium, 3 High.
*
* <p>
* Not the same as the governing rule's own severity. A statutory CTR outranks everything when
* the alert is raised, so a high-severity rule can end up recorded in {@link #alsoMatchedRules}
* and vanish from the screen. An officer disposing of low and medium alerts in a batch would
* then be handed a transaction that had tripped a high-severity rule with nothing to show it.
*/
@Column(name = "highest_severity")
private Integer highestSeverity;
protected AmlAlert() {}
public static AmlAlert raise(AmlTransactionType transactionType, Long transactionId, Client client, AmlTransactionRule rule,
@@ -94,6 +118,58 @@ public class AmlAlert extends AbstractAuditableWithUTCDateTimeCustom<Long> {
return alert;
}
public String getAlsoMatchedRules() {
return this.alsoMatchedRules;
}
public Integer getHighestSeverity() {
return this.highestSeverity;
}
public void recordHighestSeverity(final Integer severity) {
this.highestSeverity = severity;
}
/** Truncated to the column width: the record is a convenience, never a reason to fail a save. */
public void recordAlsoMatchedRules(final String ruleCodes) {
this.alsoMatchedRules = ruleCodes == null || ruleCodes.isBlank() ? null
: (ruleCodes.length() > 500 ? ruleCodes.substring(0, 500) : ruleCodes);
}
/**
* Whether this alert is being worked by somebody other than the given officer.
*
* <p>
* An alert under review belongs to whoever opened it until they decide it or hand it back. A second officer
* reading the same case and reaching their own verdict is the failure this exists to prevent: the transaction
* would carry two reviews, and whichever was written last would be the one on file.
*/
public boolean isHeldByAnother(final AppUser officer) {
return AmlAlertStatus.UNDER_REVIEW.name().equals(this.status) && this.reviewedBy != null && officer != null
&& !this.reviewedBy.getId().equals(officer.getId());
}
/**
* Brings the alert into line with the Principal Officer's conclusion on the case it opened.
*
* <p>
* An escalation is a referral, not a verdict: it says the AML Officer wanted the case looked at. The verdict is
* the Principal Officer's, and when they conclude the case clear the alert has to say so too. Left as ESCALATED,
* the feed would go on showing a transaction as suspicious after the bank had decided it was not - and the officer
* who cleared it would have no way to see their own conclusion reflected where the alert is worked.
*
* <p>
* Distinct from {@link #whitelist}: this does not pass the alert's own immutability guard, because it is not a
* second officer overturning a decision. It is the case this alert was referred into, coming back with its answer.
*/
public void applyStrConclusion(final boolean suspicious, final AppUser principalOfficer, final String notes) {
this.reviewedBy = principalOfficer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.disposition = (suspicious ? AmlAlertDisposition.ESCALATE : AmlAlertDisposition.WHITELIST).name();
this.status = (suspicious ? AmlAlertStatus.ESCALATED : AmlAlertStatus.WHITELISTED).name();
}
public void whitelist(AppUser reviewer, String notes) {
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
@@ -44,4 +44,18 @@ public interface AmlAlertRepository extends JpaRepository<AmlAlert, Long> {
* twice, which would put two STR cases in front of the Principal Officer for a single transaction.
*/
List<AmlAlert> findByRule_IdAndTransactionId(Long ruleId, Long transactionId);
/**
* A customer's still-open alerts inside the observation window, oldest first.
*
* <p>
* What a recurring-behaviour review acts on. Open is defined as not yet decided rather than by naming the statuses
* that are open, so a status this query does not know about is still treated as work: an alert wrongly included
* costs a reviewer a glance, one wrongly excluded is a pattern the bank never looked at. A decision about the
* customer's pattern must not reopen alerts an officer has already disposed of individually - and oldest first so
* that a partial failure leaves the longest-standing ones dealt with.
*/
@Query("SELECT a FROM AmlAlert a WHERE a.client.id = :clientId AND a.raisedOn >= :since "
+ "AND a.status NOT IN ('WHITELISTED', 'ESCALATED') ORDER BY a.raisedOn ASC")
List<AmlAlert> findOpenAlertsForClientSince(@Param("clientId") Long clientId, @Param("since") LocalDateTime since);
}
@@ -0,0 +1,163 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import org.apache.fineract.useradministration.domain.AppUser;
/**
* One rule a transaction matched, and what the officer decided about that rule.
*
* <p>
* The alert is the case; this is a finding within it. A single deposit can trip six rules where five are explained by
* the customer's declared business and the sixth is not - one verdict over the whole alert either loses that sixth
* observation or sends the other five to an STR they do not belong in.
*
* <p>
* The rule is held by code rather than by a foreign key to {@link AmlTransactionRule}, and the severity is copied in
* rather than read back through it. An alert records what was detected at the time; re-banding or retiring a rule
* afterwards must not rewrite what the officer was looking at when they decided.
*/
@Entity
@Table(name = "m_aml_alert_rule")
public class AmlAlertRule {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "rule_code", nullable = false, length = 100)
private String ruleCode;
@Column(name = "risk_category")
private Integer riskCategory;
@Column(name = "is_governing", nullable = false)
private boolean governing;
@Column(name = "disposition", length = 20)
private String disposition;
@ManyToOne
@JoinColumn(name = "reviewed_by")
private AppUser reviewedBy;
@Column(name = "reviewed_on")
private LocalDateTime reviewedOn;
@Column(name = "review_notes", length = 1000)
private String reviewNotes;
/**
* Whether the officer named this rule, as against it being closed to keep the case consistent.
*
* <p>
* Marking one rule suspicious escalates the transaction and closes every other rule under the same escalation -
* nothing may stay open behind a filed STR. Without this the record would read as though all of them had been
* examined and condemned, and on a transaction that tripped six rules, which one the officer put their name to is
* the substance of the finding.
*/
@Column(name = "directly_decided", nullable = false)
private boolean directlyDecided;
protected AmlAlertRule() {}
public static AmlAlertRule of(final AmlAlert alert, final String ruleCode, final Integer riskCategory,
final boolean governing) {
final AmlAlertRule finding = new AmlAlertRule();
finding.alert = alert;
finding.ruleCode = ruleCode;
finding.riskCategory = riskCategory;
finding.governing = governing;
return finding;
}
/**
* @param directlyDecided
* true when the officer named this rule; false when it was closed alongside one they named
*/
public void decide(final AmlAlertDisposition disposition, final AppUser reviewer, final String notes,
final boolean directlyDecided) {
this.disposition = disposition.name();
this.reviewedBy = reviewer;
this.reviewedOn = LocalDateTime.now();
this.reviewNotes = notes;
this.directlyDecided = directlyDecided;
}
public boolean isDirectlyDecided() {
return this.directlyDecided;
}
public boolean isDecided() {
return this.disposition != null;
}
public boolean isEscalated() {
return AmlAlertDisposition.ESCALATE.name().equals(this.disposition);
}
public Long getId() {
return this.id;
}
public AmlAlert getAlert() {
return this.alert;
}
public String getRuleCode() {
return this.ruleCode;
}
public Integer getRiskCategory() {
return this.riskCategory;
}
public boolean isGoverning() {
return this.governing;
}
public String getDisposition() {
return this.disposition;
}
public AppUser getReviewedBy() {
return this.reviewedBy;
}
public LocalDateTime getReviewedOn() {
return this.reviewedOn;
}
public String getReviewNotes() {
return this.reviewNotes;
}
}
@@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Collection;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlAlertRuleRepository extends JpaRepository<AmlAlertRule, Long> {
List<AmlAlertRule> findByAlertId(Long alertId);
/** Every finding across a page of alerts, in one read rather than one query per alert. */
List<AmlAlertRule> findByAlertIdIn(Collection<Long> alertIds);
}
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
/**
* How far a customer's repetition has gone, and what the bank's answer to it has to be.
*
* <p>
* A customer who surfaces once is a question; one who surfaces for the fortieth time is an answer the bank has not
* acted on. Without a ladder every review looked the same - same screen, same three buttons, same outcome - so the
* fortieth review cost an officer the same effort as the first and changed as little.
*
* <p>
* Each tier does two things: it sets how long a review holds the customer out of the queue, and it says what the
* reviewer is expected to do. The windows shorten as the tier rises, because a customer the bank is more worried
* about should come back sooner, not later.
*/
public enum AmlRecurringEscalationTier {
/** Ordinary repetition. A review stands for a quarter unless the count climbs into the next tier. */
ROUTINE(0, 90),
/**
* Enough repetition that the customer's risk rating is no longer just a KYC-time judgement. Reviewed monthly, and
* the reviewer is asked to consider whether the rating still fits.
*/
ELEVATED(7, 30),
/**
* Beyond what a branch officer should be closing on their own. Reviewed fortnightly, and the review is expected
* to be taken by someone senior.
*/
SENIOR(15, 14),
/**
* Repetition at a level that is itself the suspicion. An STR is the expected outcome, and clearing at this tier
* is a decision that has to be defensible on its own - so the review stands for a week only.
*/
MANDATORY_STR(30, 7);
private final int minimumAlertCount;
private final int reviewValidityDays;
AmlRecurringEscalationTier(final int minimumAlertCount, final int reviewValidityDays) {
this.minimumAlertCount = minimumAlertCount;
this.reviewValidityDays = reviewValidityDays;
}
/** The tier a customer sitting on this many alerts falls into. */
public static AmlRecurringEscalationTier forAlertCount(final int alertCount) {
AmlRecurringEscalationTier tier = ROUTINE;
for (final AmlRecurringEscalationTier candidate : values()) {
if (alertCount >= candidate.minimumAlertCount) {
tier = candidate;
}
}
return tier;
}
public int getMinimumAlertCount() {
return minimumAlertCount;
}
public int getReviewValidityDays() {
return reviewValidityDays;
}
/**
* How far the alert count may climb before a standing review is set aside and the customer returns early.
*
* <p>
* A validity window alone would hold a customer out while their behaviour changed underneath it: reviewed at four
* alerts and then trips forty more the same week, the review would still stand for the full quarter. Allowing the
* count to grow by the tier's own span before forcing a return keeps ordinary repetition quiet while letting a
* genuine change in behaviour break through. Tighter at the higher tiers, where less change should be tolerated.
*/
public int alertGrowthBeforeEarlyReturn() {
return switch (this) {
case ROUTINE -> 5;
case ELEVATED -> 4;
case SENIOR -> 3;
case MANDATORY_STR -> 2;
};
}
/** Whether a review at this tier is expected to raise an STR rather than close the customer off. */
public boolean expectsStr() {
return this == MANDATORY_STR;
}
}
@@ -26,6 +26,7 @@ import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.useradministration.domain.AppUser;
@@ -65,6 +66,20 @@ public class AmlRecurringReview {
@Column(name = "reviewed_on", nullable = false)
private LocalDateTime reviewedOn;
/**
* The date this review stops standing. Null on reviews taken before validity existed, which keep the older
* behaviour of lapsing the moment one more alert lands.
*/
@Column(name = "valid_until")
private LocalDate validUntil;
@Column(name = "escalation_tier", length = 20)
private String escalationTier;
/** How many of the customer's open alerts this review actually disposed of. */
@Column(name = "alerts_actioned")
private Integer alertsActioned;
protected AmlRecurringReview() {}
public static AmlRecurringReview record(final Client client, final int alertCountAtReview,
@@ -76,9 +91,22 @@ public class AmlRecurringReview {
review.notes = notes;
review.reviewedBy = reviewedBy;
review.reviewedOn = LocalDateTime.now();
// The tier follows from the count, not from what the reviewer picked: how often a customer has surfaced is a
// fact about them, and letting the outcome set it would let a customer be held at ROUTINE indefinitely by
// always being cleared.
final AmlRecurringEscalationTier tier = AmlRecurringEscalationTier.forAlertCount(alertCountAtReview);
review.escalationTier = tier.name();
review.validUntil = LocalDate.now().plusDays(tier.getReviewValidityDays());
review.alertsActioned = 0;
return review;
}
/** Records how many open alerts the review disposed of, once the caller has acted on them. */
public void recordAlertsActioned(final int count) {
this.alertsActioned = count;
}
public Long getId() {
return id;
}
@@ -106,4 +134,35 @@ public class AmlRecurringReview {
public LocalDateTime getReviewedOn() {
return reviewedOn;
}
public LocalDate getValidUntil() {
return validUntil;
}
public AmlRecurringEscalationTier getEscalationTier() {
return escalationTier == null ? AmlRecurringEscalationTier.ROUTINE : AmlRecurringEscalationTier.valueOf(escalationTier);
}
public Integer getAlertsActioned() {
return alertsActioned;
}
/**
* Whether this review still holds the customer out of the review queue.
*
* <p>
* Two ways it stops standing. Its window runs out, or the customer's behaviour moves on far enough that the
* review no longer describes them - judged by how much the alert count has grown since, against the allowance for
* the tier it was taken at. A review with no window at all is a pre-existing one, and keeps the original rule of
* lapsing as soon as one more alert arrives.
*/
public boolean stillStands(final int currentAlertCount) {
if (validUntil == null) {
return currentAlertCount <= alertCountAtReview;
}
if (LocalDate.now().isAfter(validUntil)) {
return false;
}
return currentAlertCount - alertCountAtReview < getEscalationTier().alertGrowthBeforeEarlyReturn();
}
}
@@ -0,0 +1,83 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
/**
* One alert covered by an STR case.
*
* <p>
* A case escalated from a single transaction covers that one alert. A case raised off a customer's repeated behaviour
* covers all the alerts that make up the pattern - which is what an STR to FIU-IND actually describes, and what the
* Principal Officer has to be able to read in one place.
*/
@Entity
@Table(name = "m_aml_str_case_alert")
public class AmlStrCaseAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(optional = false)
@JoinColumn(name = "str_case_id", nullable = false)
private AmlStrCase strCase;
@ManyToOne(optional = false)
@JoinColumn(name = "alert_id", nullable = false)
private AmlAlert alert;
@Column(name = "added_on_utc", nullable = false)
private LocalDateTime addedOn;
protected AmlStrCaseAlert() {}
public static AmlStrCaseAlert link(final AmlStrCase strCase, final AmlAlert alert) {
final AmlStrCaseAlert link = new AmlStrCaseAlert();
link.strCase = strCase;
link.alert = alert;
link.addedOn = LocalDateTime.now();
return link;
}
public Long getId() {
return id;
}
public AmlStrCase getStrCase() {
return strCase;
}
public AmlAlert getAlert() {
return alert;
}
public LocalDateTime getAddedOn() {
return addedOn;
}
}
@@ -0,0 +1,40 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AmlStrCaseAlertRepository extends JpaRepository<AmlStrCaseAlert, Long> {
/** The alerts one case covers, oldest first - the order the pattern happened in. */
List<AmlStrCaseAlert> findByStrCase_IdOrderByAlert_RaisedOnAsc(Long strCaseId);
/**
* The case already covering an alert, if any.
*
* <p>
* What stops a transaction reaching FIU-IND twice: an alert already inside a case is not attached to a second
* one, however it comes to be escalated again.
*/
Optional<AmlStrCaseAlert> findByAlert_Id(Long alertId);
long countByStrCase_Id(Long strCaseId);
}
@@ -20,6 +20,7 @@ package org.apache.fineract.portfolio.aml.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -28,6 +29,9 @@ public interface AmlStrCaseRepository extends JpaRepository<AmlStrCase, Long> {
long countByClient_Id(Long clientId);
/** The case already open for a transaction, so escalating it again does not open a second one. */
Optional<AmlStrCase> findByAlertId(Long alertId);
List<AmlStrCase> findByStatus(String status);
@Query("SELECT c FROM AmlStrCase c WHERE c.status = 'CONCLUDED_SUSPICIOUS' AND c.concludedOn BETWEEN :from AND :to")
@@ -84,6 +84,49 @@ public class AmlTransactionRule {
@Column(name = "is_active", nullable = false)
private boolean active;
/**
* CREDIT or DEBIT for a rule written about one direction only; null where either way qualifies.
* A rule named for withdrawals must not fire on a deposit.
*/
@Column(name = "direction", length = 10)
private String direction;
/** True where only physical cash qualifies — a cash-reporting rule is not about transfers. */
@Column(name = "cash_only", nullable = false)
private boolean cashOnly;
/**
* How many transactions must make up the window before a pattern rule fires. Structuring is
* many small transactions; without this floor a single large one satisfies the window total and
* reads as a pattern it is not.
*/
@Column(name = "min_transaction_count")
private Integer minTransactionCount;
/**
* Which customers a rule is about, where the rule is about the customer rather than the
* transaction: PEP, CHARITABLE_ORG, HIGH_RISK_BUSINESS, NEW_ACCOUNT. Null means the rule is
* about the transaction alone and so applies to everyone.
*/
@Column(name = "subject_scope", length = 30)
private String subjectScope;
public String getSubjectScope() {
return subjectScope;
}
public String getDirection() {
return direction;
}
public boolean isCashOnly() {
return cashOnly;
}
public Integer getMinTransactionCount() {
return minTransactionCount;
}
protected AmlTransactionRule() {}
/**
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.aml.domain;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -32,4 +33,10 @@ public interface AmlTransactionRuleRepository extends JpaRepository<AmlTransacti
* by hand from the ledger.
*/
Optional<AmlTransactionRule> findByRuleCode(String ruleCode);
/**
* Every rule named in the given codes, in one read. The alert screen resolves the rules each transaction matched
* into descriptions and severities; doing that a code at a time would be one query per rule per alert.
*/
List<AmlTransactionRule> findByRuleCodeIn(Collection<String> ruleCodes);
}
@@ -162,6 +162,15 @@ public class ClientApiConstants {
public static final String ageDaysParamName = "ageDays";
public static final String nationalityIdParamName = "nationalityId";
public static final String isMinorParamName = "isMinor";
/**
* Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD
* document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the
* KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult
* list (with mandatory PAN) for a client the UI classifies as a minor.
*/
public static final int MINOR_AGE_THRESHOLD = 10;
/** At or above this age a client qualifies for senior-citizen deposit rates. */
public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60;
public static final String guardianClientIdParamName = "guardianClientId";
public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName";
public static final String guardianRelationParamName = "guardianRelation";
@@ -0,0 +1,219 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* What one customer's money actually did over a period, and what that says about their rating.
*
* <p>
* A risk rating describes behaviour, and behaviour is a period rather than an event. Judging it from the alert feed
* meant a customer appeared the moment one transaction tripped a rule and was recommended for re-rating on that alone
* - a single shop purchase could argue for lowering a High rating, and a single large deposit for raising a Low one.
* Neither is a pattern.
*
* <p>
* One row is one window for one customer, computed nightly. Upgrades and downgrades read different windows, because
* the questions are not symmetric: a concern is about what the customer is doing now, and confidence needs a year of
* it. The suggestion is stored with the figures it was drawn from, so an officer reviewing it months later sees what
* the system was looking at rather than a conclusion with no working.
*/
@Entity
@Table(name = "m_client_behaviour_window")
public class ClientBehaviourWindow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "client_id", nullable = false)
private Long clientId;
@Column(name = "window_from", nullable = false)
private LocalDate windowFrom;
@Column(name = "window_to", nullable = false)
private LocalDate windowTo;
@Column(name = "txn_count")
private Integer txnCount;
@Column(name = "distinct_months")
private Integer distinctMonths;
@Column(name = "credit_total", precision = 19, scale = 6)
private BigDecimal creditTotal;
@Column(name = "debit_total", precision = 19, scale = 6)
private BigDecimal debitTotal;
@Column(name = "largest_txn", precision = 19, scale = 6)
private BigDecimal largestTxn;
@Column(name = "cash_txn_count")
private Integer cashTxnCount;
@Column(name = "cash_total", precision = 19, scale = 6)
private BigDecimal cashTotal;
@Column(name = "large_txn_count")
private Integer largeTxnCount;
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
@Column(name = "income_ratio", precision = 19, scale = 6)
private BigDecimal incomeRatio;
@Column(name = "alert_count")
private Integer alertCount;
@Column(name = "str_case_count")
private Integer strCaseCount;
@Column(name = "risk_category_at_review")
private Integer riskCategoryAtReview;
/** UP, DOWN or HOLD. */
@Column(name = "suggested_direction", length = 10)
private String suggestedDirection;
@Column(name = "suggested_category")
private Integer suggestedCategory;
@Column(name = "suggestion_reason", length = 500)
private String suggestionReason;
@Column(name = "computed_on")
private LocalDateTime computedOn;
protected ClientBehaviourWindow() {}
public static ClientBehaviourWindow of(final Long clientId, final LocalDate windowFrom, final LocalDate windowTo) {
final ClientBehaviourWindow window = new ClientBehaviourWindow();
window.clientId = clientId;
window.windowFrom = windowFrom;
window.windowTo = windowTo;
window.computedOn = LocalDateTime.now();
return window;
}
public void recordDistinctMonths(final Integer months) {
this.distinctMonths = months;
}
public Integer getDistinctMonths() {
return this.distinctMonths;
}
public void recordActivity(final Integer txnCount, final BigDecimal creditTotal, final BigDecimal debitTotal,
final BigDecimal largestTxn, final Integer cashTxnCount, final BigDecimal cashTotal, final Integer largeTxnCount) {
this.txnCount = txnCount;
this.creditTotal = creditTotal;
this.debitTotal = debitTotal;
this.largestTxn = largestTxn;
this.cashTxnCount = cashTxnCount;
this.cashTotal = cashTotal;
this.largeTxnCount = largeTxnCount;
}
public void recordMonitoring(final Integer alertCount, final Integer strCaseCount, final Integer riskCategoryAtReview) {
this.alertCount = alertCount;
this.strCaseCount = strCaseCount;
this.riskCategoryAtReview = riskCategoryAtReview;
}
/**
* Turnover measured against what the customer said to expect.
*
* <p>
* Null rather than zero when no income was declared: the ratio is unknown, not nil, and a rule that treats an
* unknown as a benign zero would clear every customer the bank never asked.
*/
public void recordDeclared(final BigDecimal income, final BigDecimal turnover, final BigDecimal periodTurnover) {
this.declaredAnnualIncome = income;
this.declaredAnnualTurnover = turnover;
final BigDecimal basis = income != null && income.signum() > 0 ? income
: (turnover != null && turnover.signum() > 0 ? turnover : null);
this.incomeRatio = basis == null || periodTurnover == null ? null
: periodTurnover.divide(basis, 6, java.math.RoundingMode.HALF_UP);
}
public void recordSuggestion(final String direction, final Integer category, final String reason) {
this.suggestedDirection = direction;
this.suggestedCategory = category;
this.suggestionReason = reason == null || reason.length() <= 500 ? reason : reason.substring(0, 500);
}
public Long getClientId() {
return this.clientId;
}
public LocalDate getWindowFrom() {
return this.windowFrom;
}
public LocalDate getWindowTo() {
return this.windowTo;
}
public Integer getTxnCount() {
return this.txnCount;
}
public BigDecimal getCashTotal() {
return this.cashTotal;
}
public Integer getLargeTxnCount() {
return this.largeTxnCount;
}
public Integer getAlertCount() {
return this.alertCount;
}
public Integer getStrCaseCount() {
return this.strCaseCount;
}
public Integer getRiskCategoryAtReview() {
return this.riskCategoryAtReview;
}
public String getSuggestedDirection() {
return this.suggestedDirection;
}
public Integer getSuggestedCategory() {
return this.suggestedCategory;
}
}
@@ -0,0 +1,43 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface ClientBehaviourWindowRepository extends JpaRepository<ClientBehaviourWindow, Long> {
List<ClientBehaviourWindow> findByClientId(Long clientId);
/**
* Clears the windows computed for a given day before the job writes that day's.
*
* <p>
* The job is a recomputation, not an append. Re-running it - after a failure, or twice in a night - must leave one
* set of windows rather than a customer appearing several times on the review screen with slightly different
* figures.
*/
@Modifying
@Query("DELETE FROM ClientBehaviourWindow w WHERE w.windowTo = :windowTo")
void deleteByWindowTo(@Param("windowTo") LocalDate windowTo);
}
@@ -291,6 +291,31 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@Column(name = "form15_fy", length = 10)
private String form15Fy;
// ── Risk profile ─────────────────────────────────────────────────────────
/**
* Customer facts the risk re-rating policy tests against, as against anything the customer transacted.
*
* <p>
* A politically exposed person is not downgraded because their activity looks ordinary — that is precisely the
* risk — and the same holds for a sanctions or adverse-media match. False means "not recorded as such", which is
* the correct reading for a book where nobody was ever asked; it is not an assertion that the customer is clean.
*/
@Column(name = "is_pep", nullable = false)
private boolean isPep;
@Column(name = "sanctions_match", nullable = false)
private boolean sanctionsMatch;
@Column(name = "adverse_media_match", nullable = false)
private boolean adverseMediaMatch;
/** What the customer told the bank to expect, which their actual turnover is measured against. */
@Column(name = "declared_annual_income", precision = 19, scale = 6)
private BigDecimal declaredAnnualIncome;
@Column(name = "declared_annual_turnover", precision = 19, scale = 6)
private BigDecimal declaredAnnualTurnover;
// ── CKYCRR ───────────────────────────────────────────────────────────────
/** Central KYC Records Registry number issued by CERSAI */
@Column(name = "ckyc_number", length = 14)
@@ -355,7 +380,7 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
this.dinVerified = false;
this.isException = false;
this.exceptionResolved = false;
this.initiatedOn = DateUtils.getLocalDateOfTenant();
this.initiatedOn = DateUtils.getBusinessLocalDate();
}
public static ClientKyc initiateKyc(final Client client, final JsonCommand command) {
@@ -484,6 +509,14 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
actualChanges.put(riskParam, newValue);
recordOverwrite(riskParam, this.riskCategory);
this.riskCategory = newValue;
// The re-KYC cycle is a property of the risk category, so moving the category moves the
// due date with it — otherwise a customer raised to High keeps a Low customer's ten-year
// renewal and is never called back in for the two-yearly review the rating requires.
final LocalDate newExpiry = recalculateExpiryForRiskCategory();
if (newExpiry != null) {
actualChanges.put("expiryDate", newExpiry);
}
}
// remarks is the audit justification itself, so a change to it is deliberately not recorded
@@ -519,6 +552,19 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.aadhaarReceiptRemarks = v);
updateDateField(command, "aadhaarReceiptTakenOn", this.aadhaarReceiptTakenOn, actualChanges,
v -> this.aadhaarReceiptTakenOn = v);
// Risk profile. These are determinations about the customer rather than about a transaction, and the
// re-rating policy reads them directly: a PEP or a sanctions match holds a rating at High however ordinary the
// period's activity looks.
updateBooleanField(command, "isPep", this.isPep, actualChanges, v -> this.isPep = v != null ? v : false);
updateBooleanField(command, "sanctionsMatch", this.sanctionsMatch, actualChanges,
v -> this.sanctionsMatch = v != null ? v : false);
updateBooleanField(command, "adverseMediaMatch", this.adverseMediaMatch, actualChanges,
v -> this.adverseMediaMatch = v != null ? v : false);
updateBigDecimalField(command, "declaredAnnualIncome", this.declaredAnnualIncome, actualChanges,
v -> this.declaredAnnualIncome = v);
updateBigDecimalField(command, "declaredAnnualTurnover", this.declaredAnnualTurnover, actualChanges,
v -> this.declaredAnnualTurnover = v);
updateStringField(command, "enterprisePan", this.enterprisePan, actualChanges, v -> this.enterprisePan = v);
updateStringField(command, "cinNumber", this.cinNumber, actualChanges, v -> this.cinNumber = v);
updateStringField(command, "dinNumber", this.dinNumber, actualChanges, v -> this.dinNumber = v);
@@ -554,10 +600,21 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
v -> this.form15Applicable = v);
updateStringField(command, "form15Type", this.form15Type, actualChanges, v -> this.form15Type = v);
updateStringField(command, "form15FY", this.form15Fy, actualChanges, v -> this.form15Fy = v);
updateBooleanField(command, "isException", this.isException, actualChanges, v -> this.isException = v != null ? v : false);
updateStringField(command, "exceptionReason", this.exceptionReason, actualChanges, v -> this.exceptionReason = v);
updateBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges, v -> this.exceptionResolved = v != null ? v : false);
updateDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges, v -> this.exceptionResolvedOn = v);
// The four exception columns are derived from whether the identity checks have passed, not
// data an officer types, so they are updated without recording an overwrite. A verified
// record demands remarks for any field it already held (see
// getFieldsOverwrittenByLastUpdate), and is_exception is never null once written — so
// counting these would make closing a settled exception impossible without a justification
// for something nobody chose. That is what left clients verified on the KYC tab while the
// exceptions register still listed them as Pending.
updateDerivedBooleanField(command, "isException", this.isException, actualChanges,
v -> this.isException = v != null ? v : false);
updateDerivedStringField(command, "exceptionReason", this.exceptionReason, actualChanges,
v -> this.exceptionReason = v);
updateDerivedBooleanField(command, "exceptionResolved", this.exceptionResolved, actualChanges,
v -> this.exceptionResolved = v != null ? v : false);
updateDerivedDateField(command, "exceptionResolvedOn", this.exceptionResolvedOn, actualChanges,
v -> this.exceptionResolvedOn = v);
updateBooleanField(command, "panVerified", this.panVerified, actualChanges, v -> this.panVerified = v != null ? v : false);
updateBooleanField(command, "aadhaarVerified", this.aadhaarVerified, actualChanges, v -> this.aadhaarVerified = v != null ? v : false);
updateBooleanField(command, "gstinVerified", this.gstinVerified, actualChanges, v -> this.gstinVerified = v != null ? v : false);
@@ -612,6 +669,44 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
* populated is the record being completed rather than altered, so it is deliberately not
* recorded — see {@link #getFieldsOverwrittenByLastUpdate()}.
*/
/*
* Derived-field variants of the update helpers above.
*
* Identical, minus the recordOverwrite call: these columns are computed from whether the
* identity checks have passed rather than entered by an officer, so replacing one is not the
* kind of change a verified record demands a justification for. They still report into
* `changes`, so the update is persisted and audited like any other.
*/
private void updateDerivedBooleanField(final JsonCommand command, final String param, final Boolean current,
final Map<String, Object> changes, final java.util.function.Consumer<Boolean> setter) {
if (command.parameterExists(param)) {
final Boolean newValue = command.booleanObjectValueOfParameterNamed(param);
if ((newValue == null && current != null) || (newValue != null && !newValue.equals(current))) {
changes.put(param, newValue);
setter.accept(newValue);
}
}
}
private void updateDerivedStringField(final JsonCommand command, final String param, final String current,
final Map<String, Object> changes, final java.util.function.Consumer<String> setter) {
if (command.isChangeInStringParameterNamed(param, current)) {
final String newValue = command.stringValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(StringUtils.defaultIfEmpty(newValue, null));
}
}
private void updateDerivedDateField(final JsonCommand command, final String param, final LocalDate current,
final Map<String, Object> changes, final java.util.function.Consumer<LocalDate> setter) {
if (command.isChangeInLocalDateParameterNamed(param, current)) {
final LocalDate newValue = command.localDateValueOfParameterNamed(param);
changes.put(param, newValue);
setter.accept(newValue);
}
}
private void recordOverwrite(final String param, final Object currentValue) {
final boolean wasPopulated = currentValue instanceof String text
? StringUtils.isNotBlank(text)
@@ -662,13 +757,13 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markCkycUploaded(final String ckycNumber, final String batchId) {
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
this.ckycBatchId = StringUtils.defaultIfEmpty(batchId, null);
this.ckycUploadedOn = DateUtils.getLocalDateOfTenant();
this.ckycUploadedOn = DateUtils.getBusinessLocalDate();
}
/** Called after successful 3rd-party verification of all required documents. */
public void markVerified(final String ckycNumber) {
this.kycStatus = ClientKycStatus.VERIFIED.getValue();
this.verifiedOn = DateUtils.getLocalDateOfTenant();
this.verifiedOn = DateUtils.getBusinessLocalDate();
this.ckycNumber = StringUtils.defaultIfEmpty(ckycNumber, null);
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
@@ -677,17 +772,45 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
}
}
/**
* Re-dates the re-KYC due date after the risk category moves.
*
* The cycle length belongs to the category the customer is in <em>now</em> (Low 10, Medium 8,
* High 2), and it is counted from the day of the change rather than from the original
* verification. A customer moved to High in year two of a Low rating is then due in two years'
* time, not immediately — dating it from {@code verifiedOn} would push most of the book into
* an overdue queue the moment a re-rating ran.
*
* <p>Only a verified KYC carries an expiry: an unverified record has nothing to renew, and
* {@link #markVerified} sets the date when it is first verified.
*
* @return the new expiry date, or {@code null} if the record carries no expiry
*/
private LocalDate recalculateExpiryForRiskCategory() {
if (this.verifiedOn == null) {
return null;
}
final KycRiskCategory risk = KycRiskCategory.fromInt(this.riskCategory);
if (risk.getReKycYears() <= 0) {
return this.expiryDate;
}
this.expiryDate = DateUtils.getBusinessLocalDate().plusYears(risk.getReKycYears());
return this.expiryDate;
}
/** Called when verification fails. */
public void markRejected(final String remarks) {
this.kycStatus = ClientKycStatus.REJECTED.getValue();
this.rejectedOn = DateUtils.getLocalDateOfTenant();
this.rejectedOn = DateUtils.getBusinessLocalDate();
this.remarks = StringUtils.defaultIfEmpty(remarks, this.remarks);
}
public void markPanVerified(final String fullName, final String firstName, final String lastName,
final String aadhaarSeedingStatus, final String category, final String status) {
this.panVerified = true;
this.panVerifiedOn = DateUtils.getLocalDateOfTenant();
this.panVerifiedOn = DateUtils.getBusinessLocalDate();
this.panFullName = StringUtils.defaultIfEmpty(fullName, null);
this.panFirstName = StringUtils.defaultIfEmpty(firstName, null);
this.panLastName = StringUtils.defaultIfEmpty(lastName, null);
@@ -713,28 +836,28 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
public void markAadhaarVerified() {
this.aadhaarVerified = true;
this.aadhaarVerifiedOn = DateUtils.getLocalDateOfTenant();
this.aadhaarVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markGstinVerified() {
this.gstinVerified = true;
this.gstinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.gstinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markCinVerified() {
this.cinVerified = true;
this.cinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.cinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified() {
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
}
public void markDinVerified(final String dinNumber, final String mcaResponseJson) {
this.dinNumber = dinNumber;
this.dinVerified = true;
this.dinVerifiedOn = DateUtils.getLocalDateOfTenant();
this.dinVerifiedOn = DateUtils.getBusinessLocalDate();
this.isDirector = true;
this.dinMcaResponse = mcaResponseJson;
}
@@ -755,7 +878,17 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
} else if (type != null && type.isNonIndividual()) {
allDone = this.panVerified && this.gstinVerified;
}
if (allDone && !getKycStatus().isVerified()) {
// Re-stamped even when the record already reads VERIFIED, because that is exactly what a
// periodic re-KYC is: an already-verified customer verified afresh.
//
// Skipping it there left verifiedOn — and with it the expiry date, and the dormancy clock
// that now measures from it — frozen at the original onboarding. An officer who re-verified
// both PAN and Aadhaar at the counter saw both rows stamped with today's date and the
// account still sitting on the inactive report, with nothing to say what was missing.
//
// Guarded on the verifications rather than the status, so this only fires when the checks
// that constitute a verification have actually been done again.
if (allDone) {
markVerified(this.ckycNumber);
}
return allDone;
@@ -798,6 +931,29 @@ public class ClientKyc extends AbstractAuditableWithUTCDateTimeCustom<Long> {
*/
public void escalateRiskCategory(Integer newCategory) {
this.riskCategory = newCategory;
// An auto-escalation moves the rating for the same reason an officer would, so it moves the
// re-KYC due date on the same terms — see recalculateExpiryForRiskCategory().
recalculateExpiryForRiskCategory();
}
public boolean isPep() {
return this.isPep;
}
public boolean isSanctionsMatch() {
return this.sanctionsMatch;
}
public boolean isAdverseMediaMatch() {
return this.adverseMediaMatch;
}
public BigDecimal getDeclaredAnnualIncome() {
return this.declaredAnnualIncome;
}
public BigDecimal getDeclaredAnnualTurnover() {
return this.declaredAnnualTurnover;
}
public String getPanNumber() {
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.portfolio.client.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -48,6 +49,18 @@ public interface ClientKycRepository extends JpaRepository<ClientKyc, Long> {
@Query("SELECT k FROM ClientKyc k WHERE k.kycStatus = :status")
List<ClientKyc> findAllByStatus(@Param("status") Integer status);
/**
* KYC records whose periodic re-KYC due date has passed, oldest first.
*
* <p>Only records that carry an expiry are considered: an expiry is set when the KYC is first
* verified, so one without a date has never been verified and has no renewal cycle to lapse.
* A rejected record is excluded for the same reason — it is not a lapsed KYC, it is one that
* never took effect, and restricting an account over it would punish the wrong thing.
*/
@Query("SELECT k FROM ClientKyc k WHERE k.expiryDate IS NOT NULL AND k.expiryDate <= :asOn "
+ "AND k.kycStatus <> :rejectedStatus ORDER BY k.expiryDate ASC")
List<ClientKyc> findLapsedAsOn(@Param("asOn") LocalDate asOn, @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
@@ -93,6 +93,26 @@ public class ClientRiskRule {
@Column(name = "order_position", nullable = false)
private Integer orderPosition;
/**
* Which of the two rule families this row belongs to - {@link #KIND_KYC} or {@link #KIND_AML}.
*
* This table carries both, and they are not interchangeable. A KYC rule is profile-driven: it
* reads occupation / income / turnover to categorise a customer at onboarding (RBI Master
* Direction on KYC, Section 12). An AML rule is behaviour-driven: it describes a transaction
* pattern and may only fire against posted transactions, surfacing in {@code m_aml_alert}
* (Section 13).
*
* The distinction used to be inferred from "has no criteria set", which migration 0448 erased
* by writing 0.0 over the NULLs - after which transaction-monitoring rules began matching
* ordinary customers at onboarding and pre-flagging them for AML. Recording the kind means a
* rule states what it is; nothing has to guess from the shape of its other columns.
*/
@Column(name = "rule_kind", length = 10)
private String ruleKind;
public static final String KIND_KYC = "KYC";
public static final String KIND_AML = "AML";
protected ClientRiskRule() {}
/**
@@ -104,8 +124,9 @@ public class ClientRiskRule {
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 String dormantFlag, final String ruleKind) {
final ClientRiskRule rule = new ClientRiskRule();
rule.ruleKind = ruleKind;
rule.ruleName = ruleName;
rule.reason = reason;
rule.riskCategory = riskCategory;
@@ -137,6 +158,19 @@ public class ClientRiskRule {
return id;
}
public String getRuleKind() {
return ruleKind;
}
/**
* True when this rule may take part in onboarding risk categorisation. Rows predating the
* rule_kind column are treated as KYC, matching how they behaved before AML rules were seeded
* into the same table.
*/
public boolean isKycRule() {
return !KIND_AML.equalsIgnoreCase(this.ruleKind);
}
public String getRuleName() {
return ruleName;
}
@@ -0,0 +1,106 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
/**
* How far an account has travelled down the restriction ladder after its periodic re-KYC lapsed.
*
* <p>
* RBI's Master Direction on KYC does not treat an overdue re-KYC as grounds to shut a customer out.
* The 2021 amendment (DOR.AML.REC.13/14.01.001/2021-22) is explicit that an account is not to be
* closed merely because periodic KYC is pending, and that any restriction follows notice and a fair
* opportunity to comply. The ladder therefore leaves the account untouched for a grace period,
* stops debits before it stops anything else, and never reaches closure on its own — closure stays
* a manual decision with its own notice.
*
* <p>
* The day counts are bank policy rather than a regulatory figure — the Direction requires "adequate
* notice" without fixing a number — so they are declared here once and read from here everywhere.
*/
public enum KycExpiryStage {
/**
* Inside the grace window. Nothing is restricted; the customer is being chased.
*/
GRACE(0),
/**
* Debits stopped, credits still accepted. The first actual restriction, and the one that brings
* the customer into the branch without putting their incoming money at risk.
*/
PARTIAL_FREEZE(90),
/**
* Both directions restricted, barring the credits that must always be let through.
*/
INOPERATIVE(180);
/** Days past the expiry date at which this stage begins. */
private final int fromDaysOverdue;
KycExpiryStage(final int fromDaysOverdue) {
this.fromDaysOverdue = fromDaysOverdue;
}
public int getFromDaysOverdue() {
return fromDaysOverdue;
}
/**
* The stage an account sits at, given how far past its re-KYC due date it is.
*
* @param daysOverdue
* days since the expiry date; zero or negative means the KYC has not lapsed
* @return the stage, or {@code null} where the KYC is still current and nothing applies
*/
public static KycExpiryStage fromDaysOverdue(final long daysOverdue) {
if (daysOverdue < 0) {
return null;
}
if (daysOverdue >= INOPERATIVE.fromDaysOverdue) {
return INOPERATIVE;
}
if (daysOverdue >= PARTIAL_FREEZE.fromDaysOverdue) {
return PARTIAL_FREEZE;
}
return GRACE;
}
public boolean isGrace() {
return this == GRACE;
}
public boolean isPartialFreeze() {
return this == PARTIAL_FREEZE;
}
public boolean isInoperative() {
return this == INOPERATIVE;
}
/** Whether debits should be stopped at this stage. */
public boolean blocksDebits() {
return this == PARTIAL_FREEZE || this == INOPERATIVE;
}
/** Whether ordinary credits should be stopped at this stage. */
public boolean blocksCredits() {
return this == INOPERATIVE;
}
}
@@ -0,0 +1,74 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* One number the risk re-rating policy is made of - a window length, a minimum transaction count, a cash threshold.
*
* <p>
* Held as data because these are the bank's risk appetite rather than engineering constants. How long a customer must
* behave before a High rating comes down is a compliance judgement, and a co-operative bank whose members transact
* three times a month needs different figures from one lending to traders.
*/
@Entity
@Table(name = "m_risk_policy_parameter")
public class RiskPolicyParameter {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "parameter_key", nullable = false, length = 80)
private String parameterKey;
@Column(name = "parameter_value", nullable = false, precision = 19, scale = 2)
private BigDecimal parameterValue;
@Column(name = "description", length = 500)
private String description;
@Column(name = "last_modified_by")
private Long lastModifiedBy;
@Column(name = "last_modified_on")
private LocalDateTime lastModifiedOn;
protected RiskPolicyParameter() {}
public String getParameterKey() {
return this.parameterKey;
}
public BigDecimal getParameterValue() {
return this.parameterValue;
}
public String getDescription() {
return this.description;
}
}
@@ -0,0 +1,23 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.client.domain;
import org.springframework.data.jpa.repository.JpaRepository;
public interface RiskPolicyParameterRepository extends JpaRepository<RiskPolicyParameter, Long> {}
@@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType {
WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), //
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
private final Integer value;
private final String code;
@@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType {
case 200 -> TRANSFER_TO_SAVINGS;
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
default -> INVALID;
};
}
@@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType {
return this.equals(INVALID);
}
public boolean isPartialReinvest() {
return this.equals(PARTIAL_REINVEST);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -208,6 +208,7 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
public static final String savingsAccounts = "savingsAccounts";
public static final String maturityInstructionIdParamName = "maturityInstructionId";
public static final String transferToSavingsIdParamName = "transferToSavingsId";
@@ -588,6 +589,10 @@ public final class DepositsApiConstants {
fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName);
fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName);
fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName);
// FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne)
fixedDepositResponseData.add("projectedInterestSchedule");
fixedDepositResponseData.add("totalProjectedInterest");
fixedDepositResponseData.add("totalValueAtMaturity");
return fixedDepositResponseData;
}
@@ -92,6 +92,9 @@ public class SavingsApiConstants {
public static final String statusParamName = "status";
public static final String subStatusParamName = "subStatus";
public static final String reasonForBlockParamName = "reasonForBlock";
/** "CASH" (default, a manual/staff-placed lien) or "CLEARING" (money still moving through the
* clearing process — a not-yet-realized cheque/DD deposit, or a presented cheque not yet settled). */
public static final String holdCategoryParamName = "holdCategory";
public static final String clientIdParamName = "clientId";
public static final String isRetailAccountParamName = "isRetailAccount";
public static final String autogenerateTransactionIdParamName = "autogenerateTransactionId";
@@ -226,6 +229,7 @@ public class SavingsApiConstants {
public static final String endDateParamName = "endDate";
public static final String introducerRequiredParamName = "introducerRequired";
public static final String shareLinkingEnabledParamName = "shareLinkingEnabled";
public static final String shareAccountIdParamName = "shareAccountId";
public static final String nominationFacilityParamName = "nominationFacility";
public static final String dormancyAlertParamName = "dormancyAlert";
public static final String interestCalculateSelectorParamName = "interestCalculateSelector";
@@ -254,7 +258,6 @@ public class SavingsApiConstants {
public static final String neftFacilityParamName = "neftFacility";
public static final String rtgsFacilityParamName = "rtgsFacility";
public static final String interBankTotalCapFacilityParamName = "interBankTotalCapFacility";
public static final String withdrawalFacilityParamName = "withdrawalFacility";
public static final String depositFacilityKycParamName = "depositFacilityKyc";
public static final String virtualDebitCardParamName = "virtualDebitCard";
@@ -157,6 +157,8 @@ public final class SavingsAccountData implements Serializable {
private LocalDate endDate;
private Boolean introducerRequired;
private Boolean shareLinkingEnabled;
private Long shareAccountId;
private String shareAccountNo;
private Boolean nominationFacility;
private Boolean dormancyAlert;
private Boolean isDormancyTrackingActiveFromProduct;
@@ -217,6 +219,21 @@ public final class SavingsAccountData implements Serializable {
private BigDecimal interestPosting;
private BigDecimal overdraftPosting;
/**
* True when this deposit pays its interest out to a linked savings account every period (FD Special).
* Such interest never stays in the account, so it must never compound -- every period earns on the
* principal alone. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean transferInterestToLinkedAccount;
public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) {
this.transferInterestToLinkedAccount = transferInterestToLinkedAccount;
}
public boolean isTransferInterestToLinkedAccount() {
return this.transferInterestToLinkedAccount;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -256,7 +256,16 @@ public final class PostingPeriod {
this.daysInYear = daysInYear;
this.compoundingPeriods = compoundingPeriods;
if (isSavingsInterestPostingAtCurrentPeriodEnd) {
if (isUserPosting) {
// "Post interest as on D" — a closure, or the same action from the account screen. The
// period it covers ends at D-1, because interest is earned up to but not including the
// day it is paid, and SavingsHelper closes the period there. The transaction itself
// still belongs on D: that is the day the member was credited, the day the branch did
// the work, and the day the cash is handed over. Dating it to the period end instead
// stamps it a day before the account was even closed, and the ledger then disagrees with
// the closure request, the voucher and the passbook — all of which carry D.
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
} else if (isSavingsInterestPostingAtCurrentPeriodEnd) {
this.dateOfPostingTransaction = periodInterval.endDate();
} else {
this.dateOfPostingTransaction = periodInterval.endDate().plusDays(1);
@@ -797,6 +797,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(),
DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only");
break;
case PARTIAL_REINVEST:
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
}
return optionData;
}