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:
+43
-5
@@ -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;
|
||||
|
||||
+20
-1
@@ -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;
|
||||
|
||||
+33
@@ -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";
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+13
@@ -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 + ".";
|
||||
|
||||
+5
@@ -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;
|
||||
|
||||
+11
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -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();
|
||||
|
||||
+14
@@ -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);
|
||||
}
|
||||
|
||||
+163
@@ -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;
|
||||
}
|
||||
}
|
||||
+31
@@ -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);
|
||||
}
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+59
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
+83
@@ -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;
|
||||
}
|
||||
}
|
||||
+40
@@ -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);
|
||||
}
|
||||
+4
@@ -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")
|
||||
|
||||
+43
@@ -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() {}
|
||||
|
||||
/**
|
||||
|
||||
+7
@@ -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);
|
||||
}
|
||||
|
||||
+9
@@ -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";
|
||||
|
||||
+219
@@ -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;
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
+171
-15
@@ -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() {
|
||||
|
||||
+13
@@ -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
|
||||
|
||||
+35
-1
@@ -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;
|
||||
}
|
||||
|
||||
+106
@@ -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;
|
||||
}
|
||||
}
|
||||
+74
@@ -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;
|
||||
}
|
||||
}
|
||||
+23
@@ -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> {}
|
||||
+7
-1
@@ -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();
|
||||
|
||||
+5
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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";
|
||||
|
||||
|
||||
+17
@@ -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,
|
||||
|
||||
+10
-1
@@ -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);
|
||||
|
||||
+4
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user