Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae69d126a9 | ||
|
|
e4a2c00d75 | ||
|
|
a8f8aa39b7 |
+2
-2
@@ -113,7 +113,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
||||
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
||||
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final Long writeOffAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||
@@ -128,7 +128,7 @@ public final class ProductToGLAccountMappingFromApiJsonDeserializer {
|
||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
}
|
||||
|
||||
|
||||
+5
-1
@@ -21,7 +21,11 @@ package org.apache.fineract.portfolio.aml.domain;
|
||||
/** Which transaction type(s) an {@link AmlTransactionRule} evaluates against. */
|
||||
public enum AmlAppliesTo {
|
||||
|
||||
SAVINGS, LOAN, TRANSFER, ALL;
|
||||
/**
|
||||
* CURRENT is a current / Cash Credit account. The engine is not yet fed current-account transactions, so a rule written
|
||||
* for it is catalogued but does not fire until it is.
|
||||
*/
|
||||
SAVINGS, LOAN, TRANSFER, CURRENT, ALL;
|
||||
|
||||
public boolean matches(AmlTransactionType transactionType) {
|
||||
return this == ALL || this.name().equals(transactionType.name());
|
||||
|
||||
+9
@@ -152,6 +152,15 @@ public class AmlTransactionRule {
|
||||
return rule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrows a rule to one direction - CREDIT (money in) or DEBIT (money out), null for either - and, when asked, to cash
|
||||
* only. The engine already honours both; a rule written without them applies to every credit and debit, cash or not.
|
||||
*/
|
||||
public void restrictTo(final String direction, final boolean cashOnly) {
|
||||
this.direction = direction;
|
||||
this.cashOnly = cashOnly;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
+35
@@ -27,6 +27,7 @@ import jakarta.persistence.JoinColumn;
|
||||
import jakarta.persistence.ManyToOne;
|
||||
import jakarta.persistence.Table;
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import org.apache.fineract.infrastructure.codes.domain.CodeValue;
|
||||
|
||||
@Entity
|
||||
@@ -118,6 +119,16 @@ public class ClientRiskRule {
|
||||
@Column(name = "cc_relevant", nullable = false)
|
||||
private boolean ccRelevant;
|
||||
|
||||
/**
|
||||
* A rule authored from the Rules Master starts on trial: it is listed first and marked new until an officer marks it
|
||||
* normal. Every rule that was already in the catalog is normal.
|
||||
*/
|
||||
@Column(name = "on_trial", nullable = false)
|
||||
private boolean onTrial;
|
||||
|
||||
@Column(name = "trial_started_on")
|
||||
private LocalDate trialStartedOn;
|
||||
|
||||
public static final String KIND_KYC = "KYC";
|
||||
public static final String KIND_AML = "AML";
|
||||
|
||||
@@ -127,6 +138,30 @@ public class ClientRiskRule {
|
||||
return ccRelevant;
|
||||
}
|
||||
|
||||
public boolean isOnTrial() {
|
||||
return onTrial;
|
||||
}
|
||||
|
||||
public LocalDate getTrialStartedOn() {
|
||||
return trialStartedOn;
|
||||
}
|
||||
|
||||
/** Puts a newly authored rule on trial from {@code startedOn}. */
|
||||
public void startTrial(final LocalDate startedOn) {
|
||||
this.onTrial = true;
|
||||
this.trialStartedOn = startedOn;
|
||||
}
|
||||
|
||||
/** Ends the trial: the rule is a normal part of the catalog. */
|
||||
public void markNormal() {
|
||||
this.onTrial = false;
|
||||
}
|
||||
|
||||
/** Marks the scenario as one that watches Cash Credit accounts, so the Rules Master CC chip lists it. */
|
||||
public void markCcRelevant(final boolean ccRelevant) {
|
||||
this.ccRelevant = ccRelevant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a rule authored from the AML Rules Master screen. Occupation and occupation-category
|
||||
* are attached separately by the caller, which owns the CodeValue lookup.
|
||||
|
||||
+3
-13
@@ -46,27 +46,17 @@ public class DelinquencyRange extends AbstractAuditableWithUTCDateTimeCustom<Lon
|
||||
@Column(name = "max_age_days", nullable = true)
|
||||
private Integer maximumAgeDays;
|
||||
|
||||
/**
|
||||
* Which ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions. Nullable for ranges created
|
||||
* before this column existed; a range with no stage is treated as informational only and does not drive
|
||||
* staging or provisioning.
|
||||
*/
|
||||
@Column(name = "stage", nullable = true)
|
||||
private Integer stage;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
|
||||
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays) {
|
||||
this.classification = classification;
|
||||
this.minimumAgeDays = minimumAgeDays;
|
||||
this.maximumAgeDays = maximumAgeDays;
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge,
|
||||
Integer stage) {
|
||||
return new DelinquencyRange(classification, minimumAge, maximumAge, stage);
|
||||
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge) {
|
||||
return new DelinquencyRange(classification, minimumAge, maximumAge);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
@@ -42,4 +42,8 @@ public interface CurrentAccountReadPlatformService {
|
||||
|
||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate);
|
||||
|
||||
/** As above; with {@code overdraftOnly}, only the Cash Credit accounts - those that allow an overdraft. */
|
||||
List<Map<String, Object>> retrieveAll(Integer offset, Integer limit, String accountNo, String clientName,
|
||||
String status, LocalDate fromDate, LocalDate toDate, LocalDate asOnDate, boolean overdraftOnly);
|
||||
}
|
||||
|
||||
-17
@@ -53,20 +53,12 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
|
||||
paymentTypeRepositoryWrapper, codeValueRepository);
|
||||
}
|
||||
|
||||
/** Only the GLs the request names are mapped; the bank maps nine and leaves the other placeholders unmapped. */
|
||||
private boolean isMapped(final JsonElement element, final String paramName) {
|
||||
return this.fromApiJsonHelper.extractLongNamed(paramName, element) != null;
|
||||
}
|
||||
|
||||
/***
|
||||
* Set of abstractions for saving Loan Products to GL Account Mappings
|
||||
***/
|
||||
|
||||
public void saveLoanToAssetAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||
final int placeHolderTypeId) {
|
||||
if (!isMapped(element, paramName)) {
|
||||
return;
|
||||
}
|
||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.ASSET, PortfolioProductType.LOAN);
|
||||
}
|
||||
|
||||
@@ -80,25 +72,16 @@ public class LoanProductToGLAccountMappingHelper extends ProductToGLAccountMappi
|
||||
|
||||
public void saveLoanToIncomeAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||
final int placeHolderTypeId) {
|
||||
if (!isMapped(element, paramName)) {
|
||||
return;
|
||||
}
|
||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.INCOME, PortfolioProductType.LOAN);
|
||||
}
|
||||
|
||||
public void saveLoanToExpenseAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||
final int placeHolderTypeId) {
|
||||
if (!isMapped(element, paramName)) {
|
||||
return;
|
||||
}
|
||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.EXPENSE, PortfolioProductType.LOAN);
|
||||
}
|
||||
|
||||
public void saveLoanToLiabilityAccountMapping(final JsonElement element, final String paramName, final Long productId,
|
||||
final int placeHolderTypeId) {
|
||||
if (!isMapped(element, paramName)) {
|
||||
return;
|
||||
}
|
||||
saveProductToAccountMapping(element, paramName, productId, placeHolderTypeId, GLAccountType.LIABILITY, PortfolioProductType.LOAN);
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -29,7 +29,6 @@ public final class DelinquencyApiConstants {
|
||||
public static final String RANGES_PARAM_NAME = "ranges";
|
||||
public static final String MINIMUMAGEDAYS_PARAM_NAME = "minimumAgeDays";
|
||||
public static final String MAXIMUMAGEDAYS_PARAM_NAME = "maximumAgeDays";
|
||||
public static final String STAGE_PARAM_NAME = "stage";
|
||||
public static final String LOCALE_PARAM_NAME = "locale";
|
||||
|
||||
}
|
||||
|
||||
-1
@@ -35,6 +35,5 @@ public class DelinquencyRangeRequest implements Serializable {
|
||||
private String classification;
|
||||
private Integer minimumAgeDays;
|
||||
private Integer maximumAgeDays;
|
||||
private Integer stage;
|
||||
private String locale;
|
||||
}
|
||||
|
||||
+3
-5
@@ -34,15 +34,13 @@ public class DelinquencyRangeData implements Serializable {
|
||||
private String classification;
|
||||
private Integer minimumAgeDays;
|
||||
private Integer maximumAgeDays;
|
||||
/** ECL stage (1, 2 or 3) this range maps to under the RBI 2026 Directions; null on ranges predating this field. */
|
||||
private Integer stage;
|
||||
|
||||
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays, stage);
|
||||
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays) {
|
||||
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays);
|
||||
}
|
||||
|
||||
public static DelinquencyRangeData reference(Long id) {
|
||||
return new DelinquencyRangeData(id, "", 0, 0, null);
|
||||
return new DelinquencyRangeData(id, "", 0, 0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+1
-5
@@ -305,7 +305,7 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
|
||||
throw new DelinquencyRangeInvalidAgesException(errorMessage, data.getMinimumAgeDays(), data.getMaximumAgeDays());
|
||||
}
|
||||
DelinquencyRange newDelinquencyRange = DelinquencyRange.instance(data.getClassification(), data.getMinimumAgeDays(),
|
||||
data.getMaximumAgeDays(), data.getStage());
|
||||
data.getMaximumAgeDays());
|
||||
return repositoryRange.saveAndFlush(newDelinquencyRange);
|
||||
} else {
|
||||
throw new PlatformDataIntegrityException("error.msg.data.integrity.issue.entity.duplicated",
|
||||
@@ -327,10 +327,6 @@ public class DelinquencyWritePlatformServiceImpl implements DelinquencyWritePlat
|
||||
delinquencyRange.setMaximumAgeDays(data.getMaximumAgeDays());
|
||||
changes.put(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, data.getMaximumAgeDays());
|
||||
}
|
||||
if (!java.util.Objects.equals(data.getStage(), delinquencyRange.getStage())) {
|
||||
delinquencyRange.setStage(data.getStage());
|
||||
changes.put(DelinquencyApiConstants.STAGE_PARAM_NAME, data.getStage());
|
||||
}
|
||||
if (!changes.isEmpty()) {
|
||||
delinquencyRange = repositoryRange.saveAndFlush(delinquencyRange);
|
||||
}
|
||||
|
||||
+2
-5
@@ -56,8 +56,7 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
|
||||
|
||||
jsonHelper.checkForUnsupportedParameters(element,
|
||||
List.of(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME,
|
||||
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.STAGE_PARAM_NAME,
|
||||
DelinquencyApiConstants.LOCALE_PARAM_NAME));
|
||||
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.LOCALE_PARAM_NAME));
|
||||
|
||||
final String localeValue = jsonHelper.extractStringNamed(DelinquencyApiConstants.LOCALE_PARAM_NAME, element);
|
||||
dataValidator.reset().parameter(DelinquencyApiConstants.LOCALE_PARAM_NAME).value(localeValue).notBlank();
|
||||
@@ -66,15 +65,13 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
|
||||
final String classification = jsonHelper.extractStringNamed(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, element);
|
||||
final Integer minimumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME, element, locale);
|
||||
final Integer maximumAge = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, element, locale);
|
||||
final Integer stage = jsonHelper.extractIntegerNamed(DelinquencyApiConstants.STAGE_PARAM_NAME, element, locale);
|
||||
dataValidator.reset().parameter(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME).value(classification).notBlank();
|
||||
dataValidator.reset().parameter(DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME).value(minimumAge).notBlank()
|
||||
.integerGreaterThanNumber(0);
|
||||
dataValidator.reset().parameter(DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME).value(maximumAge).ignoreIfNull()
|
||||
.integerGreaterThanNumber(0);
|
||||
dataValidator.reset().parameter(DelinquencyApiConstants.STAGE_PARAM_NAME).value(stage).ignoreIfNull().inMinMaxRange(1, 3);
|
||||
|
||||
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge, stage);
|
||||
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-172
@@ -137,141 +137,6 @@ public class LoanProductAdvanceExt {
|
||||
@Column(name = "npa_trigger")
|
||||
private String npaTrigger;
|
||||
|
||||
/** Months after disbursement before overdue days (SMA) start counting: Advances 9, Loans (EMI) 1. */
|
||||
@Column(name = "sma_start_after_months")
|
||||
private Integer smaStartAfterMonths;
|
||||
|
||||
/** CC/OD: stock statement older than this many days makes drawings an NPA trigger (RBI: 90). */
|
||||
@Column(name = "stock_statement_validity_days")
|
||||
private Integer stockStatementValidityDays;
|
||||
|
||||
// ---- Loan against Gold (pledged ornaments) ---------------------------
|
||||
@Column(name = "gold_max_ltv_pct")
|
||||
private BigDecimal goldMaxLtvPct;
|
||||
|
||||
@Column(name = "gold_min_purity_carat")
|
||||
private Integer goldMinPurityCarat;
|
||||
|
||||
@Column(name = "gold_rate_source")
|
||||
private String goldRateSource;
|
||||
|
||||
@Column(name = "gold_max_per_gram")
|
||||
private BigDecimal goldMaxPerGram;
|
||||
|
||||
@Column(name = "gold_min_net_weight_g")
|
||||
private BigDecimal goldMinNetWeightG;
|
||||
|
||||
@Column(name = "gold_max_net_weight_per_customer_g")
|
||||
private BigDecimal goldMaxNetWeightPerCustomerG;
|
||||
|
||||
@Column(name = "gold_allowed_items")
|
||||
private String goldAllowedItems;
|
||||
|
||||
@Column(name = "gold_appraiser_required")
|
||||
private Boolean goldAppraiserRequired;
|
||||
|
||||
@Column(name = "gold_valuation_certificate_required")
|
||||
private Boolean goldValuationCertificateRequired;
|
||||
|
||||
@Column(name = "gold_max_valuation_per_appraiser")
|
||||
private BigDecimal goldMaxValuationPerAppraiser;
|
||||
|
||||
@Column(name = "gold_packet_prefix")
|
||||
private String goldPacketPrefix;
|
||||
|
||||
@Column(name = "gold_auction_notice_days")
|
||||
private Integer goldAuctionNoticeDays;
|
||||
|
||||
@Column(name = "gold_revalue_on_npa")
|
||||
private Boolean goldRevalueOnNpa;
|
||||
|
||||
@Column(name = "gold_revaluation_charge_applicable")
|
||||
private Boolean goldRevaluationChargeApplicable;
|
||||
|
||||
@Column(name = "gold_partial_release_allowed")
|
||||
private Boolean goldPartialReleaseAllowed;
|
||||
|
||||
@Column(name = "gold_third_party_allowed")
|
||||
private Boolean goldThirdPartyAllowed;
|
||||
|
||||
@Column(name = "gold_disbursement_mode")
|
||||
private String goldDisbursementMode;
|
||||
|
||||
@Column(name = "gold_cash_disbursement_limit")
|
||||
private BigDecimal goldCashDisbursementLimit;
|
||||
|
||||
@Column(name = "gold_ltv_breach_alert_pct")
|
||||
private BigDecimal goldLtvBreachAlertPct;
|
||||
|
||||
@Column(name = "gold_auction_reserve_pct")
|
||||
private BigDecimal goldAuctionReservePct;
|
||||
|
||||
@Column(name = "gold_redeem_before_auction")
|
||||
private Boolean goldRedeemBeforeAuction;
|
||||
|
||||
// ---- Loan against Warehouse Receipt (pledged commodity) ----------------
|
||||
@Column(name = "wr_max_ltv_pct")
|
||||
private BigDecimal wrMaxLtvPct;
|
||||
|
||||
@Column(name = "wr_receipt_type")
|
||||
private String wrReceiptType;
|
||||
|
||||
@Column(name = "wr_wdra_registered")
|
||||
private Boolean wrWdraRegistered;
|
||||
|
||||
@Column(name = "wr_assay_certificate_required")
|
||||
private Boolean wrAssayCertificateRequired;
|
||||
|
||||
@Column(name = "wr_tenure_within_receipt_validity")
|
||||
private Boolean wrTenureWithinReceiptValidity;
|
||||
|
||||
@Column(name = "wr_pledge_marked_before_disbursement")
|
||||
private Boolean wrPledgeMarkedBeforeDisbursement;
|
||||
|
||||
@Column(name = "wr_commodity_groups")
|
||||
private String wrCommodityGroups;
|
||||
|
||||
@Column(name = "wr_valuation_price_source")
|
||||
private String wrValuationPriceSource;
|
||||
|
||||
@Column(name = "wr_max_loan_per_quintal")
|
||||
private BigDecimal wrMaxLoanPerQuintal;
|
||||
|
||||
@Column(name = "wr_stock_inspection_months")
|
||||
private Integer wrStockInspectionMonths;
|
||||
|
||||
@Column(name = "wr_ltv_breach_alert_pct")
|
||||
private BigDecimal wrLtvBreachAlertPct;
|
||||
|
||||
@Column(name = "wr_partial_release_allowed")
|
||||
private Boolean wrPartialReleaseAllowed;
|
||||
|
||||
@Column(name = "wr_disposal_notice_days")
|
||||
private Integer wrDisposalNoticeDays;
|
||||
|
||||
@Column(name = "wr_disposal_reserve_pct")
|
||||
private BigDecimal wrDisposalReservePct;
|
||||
|
||||
@Column(name = "wr_redeem_before_disposal")
|
||||
private Boolean wrRedeemBeforeDisposal;
|
||||
|
||||
// ---- common eligibility & document checklist (Advances and Loans) ----
|
||||
@Column(name = "elig_member_required")
|
||||
private Boolean memberRequired;
|
||||
|
||||
@Column(name = "elig_guarantor_must_be_member")
|
||||
private Boolean guarantorMustBeMember;
|
||||
|
||||
@Column(name = "elig_income_multiple")
|
||||
private BigDecimal incomeMultiple;
|
||||
|
||||
@Column(name = "elig_min_vintage_months")
|
||||
private Integer minVintageMonths;
|
||||
|
||||
/** Comma separated document codes the borrower must submit for this product. */
|
||||
@Column(name = "required_documents", length = 1000)
|
||||
private String requiredDocuments;
|
||||
|
||||
@Column(name = "interest_after_npa")
|
||||
private String interestAfterNpa;
|
||||
|
||||
@@ -599,43 +464,6 @@ public class LoanProductAdvanceExt {
|
||||
@Column(name = "loan_balance_transfer_out_allowed")
|
||||
private Boolean balanceTransferOutAllowed;
|
||||
|
||||
// ---- SICR & LGD (RBI 2026 ECL framework; floor % itself lives in the Prudential Floor master, not here) ----
|
||||
@Column(name = "sicr_days_threshold")
|
||||
private Integer sicrDaysThreshold;
|
||||
|
||||
@Column(name = "sicr_revolving_days_threshold")
|
||||
private Integer sicrRevolvingDaysThreshold;
|
||||
|
||||
@Column(name = "sicr_rating_downgrade_notches")
|
||||
private Integer sicrRatingDowngradeNotches;
|
||||
|
||||
@Column(name = "sicr_pricing_increase_trigger_pct")
|
||||
private BigDecimal sicrPricingIncreaseTriggerPct;
|
||||
|
||||
@Column(name = "sicr_assessment_basis")
|
||||
private String sicrAssessmentBasis;
|
||||
|
||||
@Column(name = "sicr_rebuttal_allowed")
|
||||
private Boolean sicrRebuttalAllowed;
|
||||
|
||||
@Column(name = "npa_out_of_order_basis")
|
||||
private String npaOutOfOrderBasis;
|
||||
|
||||
@Column(name = "crop_duration")
|
||||
private String cropDuration;
|
||||
|
||||
@Column(name = "use_own_lgd_model")
|
||||
private Boolean useOwnLgdModel;
|
||||
|
||||
@Column(name = "lgd_secured_pct")
|
||||
private BigDecimal lgdSecuredPct;
|
||||
|
||||
@Column(name = "lgd_unsecured_pct")
|
||||
private BigDecimal lgdUnsecuredPct;
|
||||
|
||||
@Column(name = "eligible_collateral_type")
|
||||
private String eligibleCollateralType;
|
||||
|
||||
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
|
||||
this.loanProduct = loanProduct;
|
||||
this.loanProductId = loanProduct.getId();
|
||||
|
||||
-41
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.data;
|
||||
|
||||
import java.io.Serializable;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import lombok.ToString;
|
||||
|
||||
@ToString
|
||||
@AllArgsConstructor
|
||||
@Getter
|
||||
@Setter
|
||||
public class PrudentialFloorData implements Serializable {
|
||||
|
||||
private Long id;
|
||||
private String productCategory;
|
||||
private Integer stage;
|
||||
private Integer durationFromDays;
|
||||
private Integer durationToDays;
|
||||
private BigDecimal securedFloorPct;
|
||||
private BigDecimal unsecuredFloorPct;
|
||||
}
|
||||
-102
@@ -1,102 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Table;
|
||||
import jakarta.persistence.Version;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import java.math.BigDecimal;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
|
||||
|
||||
/**
|
||||
* One row of the RBI 2026 ECL "prudential floor" table (Directions para 82): the minimum provisioning percentage
|
||||
* a bank must hold for a given loan product category and stage. Seeded centrally from the regulation rather than
|
||||
* entered per product - a loan product only ever declares its category ({@code loanProductCategory} on
|
||||
* {@link org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt}) and its stage (via its
|
||||
* Delinquency Bucket); the applicable floor % is always looked up here, never duplicated onto the product or the
|
||||
* range.
|
||||
*
|
||||
* Stage 1 and Stage 2 rows apply regardless of duration ({@code durationFromDays}/{@code durationToDays} are
|
||||
* null and {@code securedFloorPct} equals {@code unsecuredFloorPct}, since the Directions do not split those two
|
||||
* stages by security). Stage 3 rows are further split by how long the exposure has been in Stage 3
|
||||
* ({@code durationToDays} null means "and beyond") and by secured/unsecured portion.
|
||||
*/
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@Entity
|
||||
@Table(name = "m_prudential_floor")
|
||||
public class PrudentialFloor extends AbstractAuditableWithUTCDateTimeCustom<Long> {
|
||||
|
||||
@Column(name = "product_category", nullable = false)
|
||||
private String productCategory;
|
||||
|
||||
/** 1, 2 or 3. */
|
||||
@Column(name = "stage", nullable = false)
|
||||
private Integer stage;
|
||||
|
||||
@Column(name = "duration_from_days", nullable = true)
|
||||
private Integer durationFromDays;
|
||||
|
||||
/** Null means open-ended ("after 4 years" etc.). */
|
||||
@Column(name = "duration_to_days", nullable = true)
|
||||
private Integer durationToDays;
|
||||
|
||||
@Column(name = "secured_floor_pct", nullable = false)
|
||||
private BigDecimal securedFloorPct;
|
||||
|
||||
@Column(name = "unsecured_floor_pct", nullable = false)
|
||||
private BigDecimal unsecuredFloorPct;
|
||||
|
||||
@Version
|
||||
private Long version;
|
||||
|
||||
private PrudentialFloor(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays, Integer durationToDays,
|
||||
@NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
|
||||
this.productCategory = productCategory;
|
||||
this.stage = stage;
|
||||
this.durationFromDays = durationFromDays;
|
||||
this.durationToDays = durationToDays;
|
||||
this.securedFloorPct = securedFloorPct;
|
||||
this.unsecuredFloorPct = unsecuredFloorPct;
|
||||
}
|
||||
|
||||
public static PrudentialFloor instance(@NotNull String productCategory, @NotNull Integer stage, Integer durationFromDays,
|
||||
Integer durationToDays, @NotNull BigDecimal securedFloorPct, @NotNull BigDecimal unsecuredFloorPct) {
|
||||
return new PrudentialFloor(productCategory, stage, durationFromDays, durationToDays, securedFloorPct, unsecuredFloorPct);
|
||||
}
|
||||
|
||||
/** True when {@code daysInStage} falls within this row's duration band (always true for a Stage 1/2 row). */
|
||||
public boolean coversDuration(Integer daysInStage) {
|
||||
if (this.durationFromDays == null) {
|
||||
return true;
|
||||
}
|
||||
if (daysInStage == null) {
|
||||
return false;
|
||||
}
|
||||
boolean aboveFrom = daysInStage >= this.durationFromDays;
|
||||
boolean belowTo = this.durationToDays == null || daysInStage <= this.durationToDays;
|
||||
return aboveFrom && belowTo;
|
||||
}
|
||||
}
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface PrudentialFloorRepository extends JpaRepository<PrudentialFloor, Long> {
|
||||
|
||||
List<PrudentialFloor> findByProductCategoryAndStage(String productCategory, Integer stage);
|
||||
|
||||
List<PrudentialFloor> findByProductCategoryOrderByStageAscDurationFromDaysAsc(String productCategory);
|
||||
}
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.mapper;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.infrastructure.core.config.MapstructMapperConfig;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloor;
|
||||
import org.mapstruct.Mapper;
|
||||
|
||||
@Mapper(config = MapstructMapperConfig.class)
|
||||
public interface PrudentialFloorMapper {
|
||||
|
||||
PrudentialFloorData map(PrudentialFloor source);
|
||||
|
||||
List<PrudentialFloorData> map(List<PrudentialFloor> sources);
|
||||
}
|
||||
@@ -81,8 +81,6 @@ dependencies {
|
||||
'org.apache.avro:avro',
|
||||
'jakarta.ws.rs:jakarta.ws.rs-api',
|
||||
'org.glassfish.jersey.media:jersey-media-multipart',
|
||||
// Server-Sent Events (SseEventSink, Sse): the Approvals badge is pushed to the screen over one.
|
||||
'org.glassfish.jersey.media:jersey-media-sse',
|
||||
|
||||
'org.springframework:spring-jms',
|
||||
'org.springframework:spring-context-support',
|
||||
|
||||
-67
@@ -1570,76 +1570,9 @@ public class AccountingProcessorHelper {
|
||||
return mapping == null ? null : mapping.getGlAccount();
|
||||
}
|
||||
|
||||
/**
|
||||
* The clearing account for the savings-to-savings account transfer running on this thread, taken from a product's
|
||||
* own Transfers In Suspense mapping (see {@link #useProductTransferSuspense}). Null outside such a transfer.
|
||||
*/
|
||||
private static final ThreadLocal<GLAccount> TRANSFER_CLEARING_ACCOUNT = new ThreadLocal<>();
|
||||
|
||||
/**
|
||||
* Makes both legs of a savings-to-savings account transfer (savings to RD by auto debit, savings to savings, a
|
||||
* deposit paying out) clear through the Transfers In Suspense account set on the product's Accounting tab, rather
|
||||
* than the organisation-wide Liability Transfer financial activity.
|
||||
* <p>
|
||||
* The receiving product's mapping is used first, then the paying product's: a savings product screen does not
|
||||
* offer Transfers In Suspense, so for savings to RD it is the RD product's mapping. Both legs use the same account,
|
||||
* so it nets to nil. Where neither product has one, nothing is set and the transfer clears through Liability
|
||||
* Transfer as before.
|
||||
* <p>
|
||||
* Must be paired with {@link #clearProductTransferSuspense()} in a finally block.
|
||||
*
|
||||
* @return true when a product mapping was found and is now in use
|
||||
*/
|
||||
public boolean useProductTransferSuspense(final Long toSavingsProductId, final Long fromSavingsProductId) {
|
||||
final int transfersSuspense = CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue();
|
||||
GLAccount account = optionalLinkedGLAccountForSavingsProduct(toSavingsProductId, transfersSuspense, null);
|
||||
if (account == null) {
|
||||
account = optionalLinkedGLAccountForSavingsProduct(fromSavingsProductId, transfersSuspense, null);
|
||||
}
|
||||
if (account == null) {
|
||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||
return false;
|
||||
}
|
||||
TRANSFER_CLEARING_ACCOUNT.set(account);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void clearProductTransferSuspense() {
|
||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||
}
|
||||
|
||||
/**
|
||||
* For undoing one leg of an account transfer: reverses through the clearing account that leg was actually posted
|
||||
* to, whether a product's Transfers In Suspense or the Liability Transfer financial activity. A reversal works the
|
||||
* accounts out again rather than copying the original rows, so without this a transfer posted to a product's
|
||||
* suspense account would be reversed against Liability Transfer and leave both accounts open.
|
||||
* <p>
|
||||
* The clearing row is the credit of a withdrawal and the debit of a deposit (the largest, should a fee sit beside
|
||||
* it). Nothing is set where the leg has no journal entries. Must be paired with
|
||||
* {@link #clearProductTransferSuspense()} in a finally block.
|
||||
*/
|
||||
public void useClearingAccountPostedFor(final Long savingsTransactionId, final boolean isWithdrawal) {
|
||||
TRANSFER_CLEARING_ACCOUNT.remove();
|
||||
if (savingsTransactionId == null) {
|
||||
return;
|
||||
}
|
||||
final List<Long> found = jdbcTemplate.queryForList("""
|
||||
SELECT account_id FROM acc_gl_journal_entry
|
||||
WHERE savings_transaction_id = ? AND type_enum = ? AND reversed = false
|
||||
ORDER BY amount DESC, id
|
||||
LIMIT 1
|
||||
""", Long.class, savingsTransactionId, isWithdrawal ? JournalEntryType.CREDIT.getValue() : JournalEntryType.DEBIT.getValue());
|
||||
if (!found.isEmpty()) {
|
||||
glAccountRepository.findById(found.get(0)).ifPresent(TRANSFER_CLEARING_ACCOUNT::set);
|
||||
}
|
||||
}
|
||||
|
||||
private GLAccount getLinkedGLAccountForSavingsProduct(final Long savingsProductId, final int accountMappingTypeId,
|
||||
final Long paymentTypeId) {
|
||||
GLAccount glAccount;
|
||||
if (accountMappingTypeId == FinancialActivity.LIABILITY_TRANSFER.getValue() && TRANSFER_CLEARING_ACCOUNT.get() != null) {
|
||||
return TRANSFER_CLEARING_ACCOUNT.get();
|
||||
}
|
||||
if (isOrganizationAccount(accountMappingTypeId)) {
|
||||
FinancialActivityAccount financialActivityAccount = this.financialActivityAccountRepository
|
||||
.findByFinancialActivityTypeWithNotFoundDetection(accountMappingTypeId);
|
||||
|
||||
-11
@@ -79,17 +79,6 @@ public class MakercheckersApiResource {
|
||||
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/mine")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "List my pending Maker Checker Entries", description = "Requests the requestor submitted that still await approval. Unlike the checker inbox it needs no checker permission, so a maker can see what is pending. Example: makercheckers/mine?entityName=LOANPRODUCT")
|
||||
public List<AuditData> retrieveMyPendingCommands(@Context final UriInfo uriInfo, @BeanParam MakerCheckerRequest makerCheckerRequest) {
|
||||
final SQLBuilder extraCriteria = getExtraCriteria(makerCheckerRequest);
|
||||
final ApiRequestJsonSerializationSettings settings = apiRequestParameterHelper.process(uriInfo.getQueryParameters());
|
||||
return readPlatformService.retrieveMyPendingEntries(extraCriteria, settings.isIncludeJson());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("/searchtemplate")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
|
||||
-3
@@ -33,9 +33,6 @@ public interface AuditReadPlatformService {
|
||||
|
||||
List<AuditData> retrieveAllEntriesToBeChecked(SQLBuilder extraCriteria, boolean includeJson);
|
||||
|
||||
/** Requests the current user submitted that still await a checker, whatever the user's own checker permissions. */
|
||||
List<AuditData> retrieveMyPendingEntries(SQLBuilder extraCriteria, boolean includeJson);
|
||||
|
||||
AuditData retrieveAuditEntry(Long auditId);
|
||||
|
||||
AuditSearchData retrieveSearchTemplate(String useType);
|
||||
|
||||
+1
-8
@@ -214,17 +214,10 @@ public class AuditReadPlatformServiceImpl implements AuditReadPlatformService {
|
||||
return retrieveEntries("makerchecker", extraCriteria, " order by aud.id, mk.username", includeJson);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<AuditData> retrieveMyPendingEntries(final SQLBuilder extraCriteria, final boolean includeJson) {
|
||||
extraCriteria.addCriteria("aud.status = ", 2);
|
||||
extraCriteria.addCriteria("aud.maker_id = ", this.context.authenticatedUser().getId());
|
||||
return retrieveEntries("makerchecker-mine", extraCriteria, " order by aud.id, mk.username", includeJson);
|
||||
}
|
||||
|
||||
private List<AuditData> retrieveEntries(final String useType, final SQLBuilder extraCriteria, final String groupAndOrderBySQL,
|
||||
final boolean includeJson) {
|
||||
|
||||
if ((!useType.equals("audit") && !useType.equals("makerchecker") && !useType.equals("makerchecker-mine"))) {
|
||||
if ((!useType.equals("audit") && !useType.equals("makerchecker"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.invalid.auditSearchTemplate.useType",
|
||||
"Invalid Audit Search Template UseType: " + useType);
|
||||
}
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ public class TenantMenuAccessApiResource {
|
||||
"MENU_CASHIER", "MENU_CLERK", "MENU_BRANCH_OPS", "MENU_CLEARING", "MENU_DEPOSITS", "MENU_ADVANCES",
|
||||
"MENU_LOAN_ORIGINATION", "MENU_SHARE", "MENU_NPA", "MENU_GENERAL_LEDGER", "MENU_SUB_LEDGER", "MENU_ACCOUNTING",
|
||||
"MENU_CKYC_MGMT", "MENU_INSURANCE", "MENU_DIGITAL", "MENU_HRM", "MENU_ADMIN", "MENU_RBI", "MENU_INVESTMENT",
|
||||
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT");
|
||||
"MENU_REPORTS", "MENU_OTHERS", "MENU_CONFIG_WIZARD", "MENU_CHEQUEBOOK", "MENU_LETTER_MGMT", "MENU_CREDIT_BUREAU");
|
||||
|
||||
/**
|
||||
* This tenant's own menu-access list, for the tenant named in {@code Fineract-Platform-TenantId}.
|
||||
|
||||
+13
-43
@@ -144,22 +144,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
|
||||
final SavingsTransactionBooleanValues transactionBooleanValues = new SavingsTransactionBooleanValues(isAccountTransfer,
|
||||
isRegularTransaction, fromSavingsAccount.isWithdrawalFeeApplicableForTransfer(), isInterestTransfer, isWithdrawBalance);
|
||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt,
|
||||
transactionDate, transactionAmount, paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||
|
||||
final Long toSavingsId = command.longValueOfParameterNamed(toAccountIdParamName);
|
||||
final SavingsAccount toSavingsAccount = this.savingsAccountAssembler.assembleFrom(toSavingsId, backdatedTxnsAllowedTill);
|
||||
|
||||
// As in transferFunds: both legs clear through the product's Transfers In Suspense where one is mapped.
|
||||
final SavingsAccountTransaction withdrawal;
|
||||
final SavingsAccountTransaction deposit;
|
||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
||||
try {
|
||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, fmt, transactionDate, transactionAmount,
|
||||
paymentDetail, transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate, transactionAmount,
|
||||
paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
} finally {
|
||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||
}
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
|
||||
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
|
||||
@@ -285,27 +277,14 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
if (accountTransfer.getToLoanTransaction() != null) {
|
||||
this.loanAccountDomainService.reverseTransfer(accountTransfer.getToLoanTransaction());
|
||||
}
|
||||
// Each leg is reversed through the clearing account it was posted to (a product's Transfers In Suspense or
|
||||
// Liability Transfer), so the reversal clears that same account.
|
||||
if (accountTransfer.getFromTransaction() != null) {
|
||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getFromTransaction().getId(), true);
|
||||
try {
|
||||
this.savingsAccountWritePlatformService.undoTransaction(
|
||||
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(),
|
||||
accountTransfer.getFromTransaction().getId(), true);
|
||||
} finally {
|
||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||
}
|
||||
accountTransfer.accountTransferDetails().fromSavingsAccount().getId(), accountTransfer.getFromTransaction().getId(),
|
||||
true);
|
||||
}
|
||||
if (accountTransfer.getToSavingsTransaction() != null) {
|
||||
this.accountingProcessorHelper.useClearingAccountPostedFor(accountTransfer.getToSavingsTransaction().getId(), false);
|
||||
try {
|
||||
this.savingsAccountWritePlatformService.undoTransaction(
|
||||
accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
||||
this.savingsAccountWritePlatformService.undoTransaction(accountTransfer.accountTransferDetails().toSavingsAccount().getId(),
|
||||
accountTransfer.getToSavingsTransaction().getId(), true);
|
||||
} finally {
|
||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||
}
|
||||
}
|
||||
accountTransfer.reverse();
|
||||
this.accountTransferRepository.save(accountTransfer);
|
||||
@@ -428,22 +407,13 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
|
||||
transactionDate = transactionDate.plusDays(1);
|
||||
}
|
||||
|
||||
// Both legs clear through the Transfers In Suspense account on the product's Accounting tab (the receiving
|
||||
// product's, else the paying one's) instead of the organisation-wide Liability Transfer mapping.
|
||||
final SavingsAccountTransaction withdrawal;
|
||||
final SavingsAccountTransaction deposit;
|
||||
this.accountingProcessorHelper.useProductTransferSuspense(toSavingsAccount.productId(), fromSavingsAccount.productId());
|
||||
try {
|
||||
withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount, accountTransferDTO.getFmt(),
|
||||
transactionDate, accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
|
||||
transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||
final SavingsAccountTransaction withdrawal = this.savingsAccountDomainService.handleWithdrawal(fromSavingsAccount,
|
||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), transactionBooleanValues, backdatedTxnsAllowedTill);
|
||||
|
||||
deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, accountTransferDTO.getFmt(), transactionDate,
|
||||
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(), isAccountTransfer,
|
||||
isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
} finally {
|
||||
this.accountingProcessorHelper.clearProductTransferSuspense();
|
||||
}
|
||||
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
|
||||
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
|
||||
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
|
||||
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
|
||||
|
||||
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
|
||||
|
||||
+882
-13
@@ -22,17 +22,24 @@ import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.NotFoundException;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.TemporalAdjusters;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
@@ -41,13 +48,19 @@ import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* How much came into, and went out of, each Cash Credit account over a window, set against what the customer declared
|
||||
* and what the bank sanctioned.
|
||||
* and what the bank sanctioned - and, for one account, where it came from and went, by what mode, what that adds up to,
|
||||
* and which of the bank's CC rules it hits and which it does not.
|
||||
*
|
||||
* <p>
|
||||
* Read straight from the posted current-account transactions, so it works today without the AML engine being fed those
|
||||
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; every active current account
|
||||
* is listed, with that flag, so a plain current account is not hidden from view. Accounts are limited to the caller's
|
||||
* branch hierarchy, and the busiest are listed first.
|
||||
* transactions. A Cash Credit account is a current account allowed to draw an overdraft; only those are listed. Accounts are
|
||||
* limited to the caller's branch hierarchy, and the busiest are listed first.
|
||||
*
|
||||
* <p>
|
||||
* The rule checks apply each CC rule's own logic to the account's own transactions here, the way the engine would if it were
|
||||
* fed them. A rule that turns on an officer's judgement, or that the system does not yet check, is shown as not checked
|
||||
* rather than as passed. The observations and the fixed values behind the CC scenarios are indicative starting values, not
|
||||
* findings; the bank's compliance team sets the real thresholds.
|
||||
*/
|
||||
@Path("/v1/aml/cc-tracking")
|
||||
@Component
|
||||
@@ -55,6 +68,30 @@ import org.springframework.stereotype.Component;
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public class AmlCcTrackingApiResource {
|
||||
|
||||
// ── Starting values for the observations and the CC scenarios (indicative; to be set by the bank) ─────────────────
|
||||
/** Credits and debits this many times the declared turnover (or the sanctioned limit) are flagged. */
|
||||
private static final BigDecimal TURNOVER_MULTIPLE = BigDecimal.valueOf(3);
|
||||
/** A credit is "rapid in-out" when this share of it is paid out again within {@link #RAPID_DAYS} days. */
|
||||
private static final BigDecimal RAPID_SHARE = new BigDecimal("0.80");
|
||||
private static final int RAPID_DAYS = 3;
|
||||
/** One party handling this share of all the money moved is a concentration. */
|
||||
private static final BigDecimal CONCENTRATION_SHARE = new BigDecimal("0.50");
|
||||
/** Cash making up this share of credits or debits is a high cash share. */
|
||||
private static final BigDecimal CASH_SHARE = new BigDecimal("0.50");
|
||||
/** Peak use of the sanctioned limit worth noting, and the days within which a return to credit counts as "cleared". */
|
||||
private static final BigDecimal LIMIT_USE = new BigDecimal("0.80");
|
||||
private static final int CLEARED_DAYS = 7;
|
||||
/** A party both paying in and being paid, the smaller side at least this share of the larger. */
|
||||
private static final BigDecimal TWO_WAY_SHARE = new BigDecimal("0.50");
|
||||
/** Money with no counterparty recorded, as a share of everything moved. */
|
||||
private static final BigDecimal UNRECORDED_SHARE = new BigDecimal("0.50");
|
||||
/** The window's turnover this many times the account's own earlier average is a spike. */
|
||||
private static final BigDecimal SPIKE_MULTIPLE = BigDecimal.valueOf(3);
|
||||
|
||||
private static final String HIT = "HIT";
|
||||
private static final String NO_HIT = "NO_HIT";
|
||||
private static final String NOT_CHECKED = "NOT_CHECKED";
|
||||
|
||||
/** Current-account transaction types 1 and 2 are a deposit and a withdrawal, as on a savings account. */
|
||||
private static final String SQL = """
|
||||
SELECT ca.id AS account_id, ca.account_no, c.id AS client_id, c.display_name AS client_name,
|
||||
@@ -75,16 +112,40 @@ public class AmlCcTrackingApiResource {
|
||||
AND t.transaction_type_enum IN (1, 2) AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE ca.status_enum = 300
|
||||
WHERE ca.status_enum = 300 AND ca.allow_overdraft = true
|
||||
AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
||||
GROUP BY ca.id, c.id, o.id, k.id
|
||||
ORDER BY SUM(t.amount) DESC NULLS LAST, ca.account_no
|
||||
""";
|
||||
|
||||
private static final String TRAIL_SELECT = """
|
||||
SELECT t.id, t.current_account_id, t.transaction_date, t.transaction_type_enum, t.amount, t.ref_no, t.remarks,
|
||||
t.running_balance_derived, pt.value AS payment_type, COALESCE(pt.is_cash_payment, false) AS is_cash,
|
||||
pd.account_number AS cp_account, pd.bank_number AS cp_bank, pd.routing_code AS cp_ifsc, pd.check_number AS cheque_no
|
||||
FROM m_current_account_transaction t
|
||||
LEFT JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||
LEFT JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
|
||||
AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||
""";
|
||||
|
||||
/** The CC rules: every AML scenario marked as watching a CC account, with the live-transaction rule behind it, if any. */
|
||||
private static final String RULES_SQL = """
|
||||
SELECT r.id, r.rule_name, r.risk_category, r.coverage_status, r.on_trial,
|
||||
t.rule_type, t.threshold_amount, t.min_limit_amount, t.max_limit_amount, t.window_days, t.direction,
|
||||
t.cash_only, t.min_transaction_count, t.subject_scope, t.is_active
|
||||
FROM m_client_risk_rule r
|
||||
LEFT JOIN m_aml_transaction_rule t ON t.source_client_risk_rule_id = r.id
|
||||
WHERE r.cc_relevant = true AND r.rule_kind = 'AML'
|
||||
ORDER BY r.on_trial DESC, r.id
|
||||
""";
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── The list ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
public String retrieve(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||
@@ -92,22 +153,28 @@ public class AmlCcTrackingApiResource {
|
||||
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate from = asOf.minusDays(window);
|
||||
|
||||
final List<Map<String, Object>> accountRows = this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
|
||||
java.sql.Date.valueOf(asOf), hierarchy + "%");
|
||||
|
||||
// The transactions and earlier history of all the accounts at once, to check each against the CC rules
|
||||
final Map<Long, List<Txn>> txnsByAccount = loadTxns(from, asOf, null);
|
||||
final Map<Long, BigDecimal> priorVolume = priorVolumes(from, window);
|
||||
final List<CcRule> rules = loadRules();
|
||||
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
int rank = 0;
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(SQL, java.sql.Date.valueOf(from),
|
||||
java.sql.Date.valueOf(asOf), hierarchy + "%")) {
|
||||
for (final Map<String, Object> r : accountRows) {
|
||||
final long accountId = ((Number) r.get("account_id")).longValue();
|
||||
final BigDecimal credits = money(r.get("credits"));
|
||||
final BigDecimal debits = money(r.get("debits"));
|
||||
final BigDecimal limit = money(r.get("overdraft_limit"));
|
||||
final BigDecimal balance = money(r.get("account_balance_derived"));
|
||||
final BigDecimal declaredMonthly = r.get("declared_monthly_turnover") == null ? null : money(r.get("declared_monthly_turnover"));
|
||||
// What the declared monthly turnover comes to over this window, so a 7-day look is not judged against a month.
|
||||
final BigDecimal declaredForWindow = declaredMonthly == null ? null
|
||||
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
|
||||
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window);
|
||||
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("rank", ++rank);
|
||||
row.put("accountId", r.get("account_id"));
|
||||
row.put("accountId", accountId);
|
||||
row.put("accountNo", r.get("account_no"));
|
||||
row.put("clientId", r.get("client_id"));
|
||||
row.put("clientName", r.get("client_name"));
|
||||
@@ -115,7 +182,6 @@ public class AmlCcTrackingApiResource {
|
||||
row.put("isCc", Boolean.TRUE.equals(r.get("allow_overdraft")));
|
||||
row.put("sanctionedLimit", limit);
|
||||
row.put("balance", balance);
|
||||
// How much of the sanctioned limit is drawn: the overdrawn part of the balance over the limit.
|
||||
row.put("limitDrawnPercent", limit.signum() > 0 && balance.signum() < 0
|
||||
? balance.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
||||
row.put("declaredMonthlyTurnover", declaredMonthly);
|
||||
@@ -128,10 +194,28 @@ public class AmlCcTrackingApiResource {
|
||||
row.put("cashDebits", money(r.get("cash_debits")));
|
||||
row.put("cashCreditPercent", credits.signum() > 0
|
||||
? money(r.get("cash_credits")).multiply(BigDecimal.valueOf(100)).divide(credits, 1, RoundingMode.HALF_UP) : null);
|
||||
// Credits and debits as a multiple of what the customer said the business turns over in this window.
|
||||
row.put("creditsVsDeclared", ratio(credits, declaredForWindow));
|
||||
row.put("debitsVsDeclared", ratio(debits, declaredForWindow));
|
||||
row.put("lastTxnDate", r.get("last_txn_date") == null ? null : r.get("last_txn_date").toString());
|
||||
|
||||
final List<Map<String, Object>> checks = checkRules(rules, txnsByAccount.getOrDefault(accountId, List.of()), limit,
|
||||
declaredForWindow, priorVolume.get(accountId), window);
|
||||
int hit = 0;
|
||||
int noHit = 0;
|
||||
int notChecked = 0;
|
||||
for (final Map<String, Object> c : checks) {
|
||||
final String status = (String) c.get("status");
|
||||
if (HIT.equals(status)) {
|
||||
hit++;
|
||||
} else if (NO_HIT.equals(status)) {
|
||||
noHit++;
|
||||
} else {
|
||||
notChecked++;
|
||||
}
|
||||
}
|
||||
row.put("rulesHit", hit);
|
||||
row.put("rulesNoHit", noHit);
|
||||
row.put("rulesNotChecked", notChecked);
|
||||
rows.add(row);
|
||||
}
|
||||
|
||||
@@ -143,6 +227,791 @@ public class AmlCcTrackingApiResource {
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
// ── One account ──────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every credit and debit on one CC account in the window, with the mode and the party on the other side where the
|
||||
* payment recorded them, totalled by mode and by party, an analysis of what it adds up to, and the CC rules checked against
|
||||
* it. The account's own branch must be at or below the caller's.
|
||||
*/
|
||||
@GET
|
||||
@Path("{accountId}/trail")
|
||||
public String trail(@PathParam("accountId") final Long accountId, @QueryParam("days") @DefaultValue("30") final int days) {
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy();
|
||||
final List<Map<String, Object>> account = this.jdbcTemplate.queryForList("""
|
||||
SELECT ca.id, ca.account_no, c.display_name AS client_name, o.name AS branch_name, ca.overdraft_limit,
|
||||
ca.account_balance_derived, ca.allow_overdraft,
|
||||
COALESCE(NULLIF(c.monthly_turnover, 0), NULLIF(k.declared_annual_turnover, 0) / 12) AS declared_monthly_turnover
|
||||
FROM m_current_account ca
|
||||
LEFT JOIN m_client c ON c.id = ca.client_id
|
||||
LEFT JOIN m_client_kyc k ON k.client_id = c.id
|
||||
LEFT JOIN m_office o ON o.id = COALESCE(ca.office_id, c.office_id)
|
||||
WHERE ca.id = ? AND COALESCE(ca.office_id, c.office_id) IN (SELECT id FROM m_office WHERE hierarchy LIKE ?)
|
||||
""", accountId, hierarchy + "%");
|
||||
if (account.isEmpty()) {
|
||||
throw new NotFoundException("Account not found: " + accountId);
|
||||
}
|
||||
final int window = Math.min(Math.max(days, 1), 366);
|
||||
final LocalDate asOf = DateUtils.getBusinessLocalDate();
|
||||
final LocalDate from = asOf.minusDays(window);
|
||||
|
||||
final List<Txn> txns = loadTxns(from, asOf, accountId).getOrDefault(accountId, List.of());
|
||||
|
||||
final Map<String, Object> a = account.get(0);
|
||||
final BigDecimal limit = money(a.get("overdraft_limit"));
|
||||
final BigDecimal balance = money(a.get("account_balance_derived"));
|
||||
final BigDecimal declaredMonthly = a.get("declared_monthly_turnover") == null ? null : money(a.get("declared_monthly_turnover"));
|
||||
final BigDecimal declaredForWindow = scaledToWindow(declaredMonthly, window);
|
||||
|
||||
// Newest first for the list, oldest first for the analysis
|
||||
final List<Map<String, Object>> txnRows = new ArrayList<>();
|
||||
for (int i = txns.size() - 1; i >= 0; i--) {
|
||||
final Txn t = txns.get(i);
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", t.id);
|
||||
row.put("date", t.date.toString());
|
||||
row.put("direction", t.credit ? "IN" : "OUT");
|
||||
row.put("amount", t.amount);
|
||||
row.put("mode", t.mode);
|
||||
row.put("isCash", t.cash);
|
||||
row.put("reference", t.reference);
|
||||
row.put("chequeNo", t.chequeNo);
|
||||
row.put("counterpartyAccount", t.partyAccount);
|
||||
row.put("counterpartyBank", t.partyBank);
|
||||
row.put("counterpartyIfsc", t.partyIfsc);
|
||||
row.put("remarks", t.remarks);
|
||||
row.put("balanceAfter", t.balanceAfter);
|
||||
txnRows.add(row);
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("accountNo", a.get("account_no"));
|
||||
out.put("clientName", a.get("client_name"));
|
||||
out.put("branchName", a.get("branch_name"));
|
||||
out.put("isCc", Boolean.TRUE.equals(a.get("allow_overdraft")));
|
||||
out.put("sanctionedLimit", limit);
|
||||
out.put("balance", balance);
|
||||
out.put("declaredMonthlyTurnover", declaredMonthly);
|
||||
out.put("from", from.toString());
|
||||
out.put("asOf", asOf.toString());
|
||||
out.put("days", window);
|
||||
out.put("transactions", txnRows);
|
||||
out.putAll(analyse(txns, limit, declaredForWindow));
|
||||
out.put("ruleChecks", checkRules(loadRules(), txns, limit, declaredForWindow, priorVolumes(from, window).get(accountId), window));
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
// ── Loading ──────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** The window's transactions, oldest first, by account - for one account, or for every current account. */
|
||||
private Map<Long, List<Txn>> loadTxns(final LocalDate from, final LocalDate asOf, final Long accountId) {
|
||||
final String sql = TRAIL_SELECT + (accountId == null ? "" : " AND t.current_account_id = ?")
|
||||
+ " ORDER BY t.transaction_date ASC, t.id ASC";
|
||||
final List<Map<String, Object>> rows = accountId == null
|
||||
? this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf))
|
||||
: this.jdbcTemplate.queryForList(sql, java.sql.Date.valueOf(from), java.sql.Date.valueOf(asOf), accountId);
|
||||
final Map<Long, List<Txn>> byAccount = new HashMap<>();
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Txn t = new Txn();
|
||||
t.id = ((Number) r.get("id")).longValue();
|
||||
t.date = ((java.sql.Date) r.get("transaction_date")).toLocalDate();
|
||||
t.credit = ((Number) r.get("transaction_type_enum")).intValue() == 1;
|
||||
t.amount = money(r.get("amount"));
|
||||
t.reference = (String) r.get("ref_no");
|
||||
t.remarks = (String) r.get("remarks");
|
||||
t.balanceAfter = r.get("running_balance_derived") == null ? null : money(r.get("running_balance_derived"));
|
||||
t.cash = Boolean.TRUE.equals(r.get("is_cash"));
|
||||
t.chequeNo = (String) r.get("cheque_no");
|
||||
t.partyAccount = (String) r.get("cp_account");
|
||||
t.partyBank = (String) r.get("cp_bank");
|
||||
t.partyIfsc = (String) r.get("cp_ifsc");
|
||||
t.mode = modeOf(t.cash, (String) r.get("payment_type"), t.chequeNo, t.remarks);
|
||||
t.party = partyOf(t);
|
||||
byAccount.computeIfAbsent(((Number) r.get("current_account_id")).longValue(), k -> new ArrayList<>()).add(t);
|
||||
}
|
||||
return byAccount;
|
||||
}
|
||||
|
||||
/** The average turnover (credits plus debits) of each account over the three windows before this one, where there is any. */
|
||||
private Map<Long, BigDecimal> priorVolumes(final LocalDate from, final int window) {
|
||||
final Map<Long, BigDecimal> result = new HashMap<>();
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
|
||||
SELECT t.current_account_id, SUM(t.amount) AS total
|
||||
FROM m_current_account_transaction t
|
||||
WHERE t.is_reversed = false AND t.transaction_type_enum IN (1, 2)
|
||||
AND t.transaction_date > ? AND t.transaction_date <= ?
|
||||
GROUP BY t.current_account_id
|
||||
""", java.sql.Date.valueOf(from.minusDays(3L * window)), java.sql.Date.valueOf(from));
|
||||
for (final Map<String, Object> r : rows) {
|
||||
result.put(((Number) r.get("current_account_id")).longValue(),
|
||||
money(r.get("total")).divide(BigDecimal.valueOf(3), 2, RoundingMode.HALF_UP));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── The CC rules ─────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One CC rule: the catalog entry and, where there is one, the live-transaction rule behind it. */
|
||||
private static final class CcRule {
|
||||
long id;
|
||||
String name;
|
||||
int riskCategory;
|
||||
String coverage;
|
||||
boolean onTrial;
|
||||
String ruleType;
|
||||
BigDecimal threshold;
|
||||
BigDecimal min;
|
||||
BigDecimal max;
|
||||
Integer windowDays;
|
||||
String direction;
|
||||
boolean cashOnly;
|
||||
Integer minCount;
|
||||
String subjectScope;
|
||||
boolean active;
|
||||
}
|
||||
|
||||
private List<CcRule> loadRules() {
|
||||
final List<CcRule> rules = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(RULES_SQL)) {
|
||||
final CcRule rule = new CcRule();
|
||||
rule.id = ((Number) r.get("id")).longValue();
|
||||
rule.name = (String) r.get("rule_name");
|
||||
rule.riskCategory = r.get("risk_category") == null ? 0 : ((Number) r.get("risk_category")).intValue();
|
||||
rule.coverage = (String) r.get("coverage_status");
|
||||
rule.onTrial = Boolean.TRUE.equals(r.get("on_trial"));
|
||||
rule.ruleType = (String) r.get("rule_type");
|
||||
rule.threshold = r.get("threshold_amount") == null ? null : money(r.get("threshold_amount"));
|
||||
rule.min = r.get("min_limit_amount") == null ? null : money(r.get("min_limit_amount"));
|
||||
rule.max = r.get("max_limit_amount") == null ? null : money(r.get("max_limit_amount"));
|
||||
rule.windowDays = r.get("window_days") == null ? null : ((Number) r.get("window_days")).intValue();
|
||||
rule.direction = (String) r.get("direction");
|
||||
rule.cashOnly = Boolean.TRUE.equals(r.get("cash_only"));
|
||||
rule.minCount = r.get("min_transaction_count") == null ? null : ((Number) r.get("min_transaction_count")).intValue();
|
||||
rule.subjectScope = (String) r.get("subject_scope");
|
||||
rule.active = r.get("is_active") == null || Boolean.TRUE.equals(r.get("is_active"));
|
||||
rules.add(rule);
|
||||
}
|
||||
return rules;
|
||||
}
|
||||
|
||||
/**
|
||||
* Each CC rule against one account's transactions. A rule with a live-transaction rule behind it is checked the way the
|
||||
* engine would check it; a CC scenario is checked by its own test; a rule that turns on an officer's judgement, or that the
|
||||
* system does not yet check, is NOT_CHECKED - never reported as passed.
|
||||
*/
|
||||
private List<Map<String, Object>> checkRules(final List<CcRule> rules, final List<Txn> txns, final BigDecimal limit,
|
||||
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume, final int window) {
|
||||
final Facts f = facts(txns, limit);
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final CcRule rule : rules) {
|
||||
final String[] result;
|
||||
final String kind;
|
||||
if (rule.name != null && rule.name.startsWith("CC - ")) {
|
||||
kind = "CC scenario";
|
||||
result = scenario(rule, f, txns, limit, declaredForWindow, priorAverageVolume);
|
||||
} else if (rule.ruleType != null) {
|
||||
kind = "Transaction rule";
|
||||
result = transactionRule(rule, txns, window);
|
||||
} else if ("MANUAL_REVIEW".equals(rule.coverage)) {
|
||||
kind = "Manual review";
|
||||
result = new String[] { NOT_CHECKED, "Turns on an officer's judgement, so the system does not check it." };
|
||||
} else {
|
||||
kind = "Planned";
|
||||
result = new String[] { NOT_CHECKED, "Planned: the system does not check this yet." };
|
||||
}
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("ruleId", rule.id);
|
||||
m.put("name", rule.name);
|
||||
m.put("kind", kind);
|
||||
m.put("riskCategory", rule.riskCategory);
|
||||
m.put("onTrial", rule.onTrial);
|
||||
m.put("status", result[0]);
|
||||
m.put("detail", result[1]);
|
||||
out.add(m);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** A live-transaction rule applied to this account's transactions, with the engine's own meaning of each rule type. */
|
||||
private String[] transactionRule(final CcRule rule, final List<Txn> txns, final int tabWindow) {
|
||||
if (!rule.active) {
|
||||
return new String[] { NOT_CHECKED, "The rule is switched off." };
|
||||
}
|
||||
if (rule.subjectScope != null) {
|
||||
return new String[] { NOT_CHECKED, "Applies to a kind of customer (" + rule.subjectScope + "), not to the transactions." };
|
||||
}
|
||||
if (rule.threshold == null) {
|
||||
return new String[] { NOT_CHECKED, "The rule has no amount, so there is nothing to compare a transaction with." };
|
||||
}
|
||||
final List<Txn> pool = new ArrayList<>();
|
||||
for (final Txn t : txns) {
|
||||
if (rule.direction != null && "CREDIT".equalsIgnoreCase(rule.direction) != t.credit) {
|
||||
continue;
|
||||
}
|
||||
if (rule.cashOnly && !t.cash) {
|
||||
continue;
|
||||
}
|
||||
pool.add(t);
|
||||
}
|
||||
final String scope = (rule.direction == null ? "credit or debit" : rule.direction.toLowerCase()) + (rule.cashOnly ? " cash" : "")
|
||||
+ " transaction";
|
||||
|
||||
if ("THRESHOLD".equals(rule.ruleType)) {
|
||||
Txn largest = null;
|
||||
int count = 0;
|
||||
for (final Txn t : pool) {
|
||||
if (t.amount.compareTo(rule.threshold) >= 0) {
|
||||
count++;
|
||||
}
|
||||
if (largest == null || t.amount.compareTo(largest.amount) > 0) {
|
||||
largest = t;
|
||||
}
|
||||
}
|
||||
if (count > 0) {
|
||||
return new String[] { HIT, count + " " + scope + "(s) of " + rupees(rule.threshold) + " or more; the largest is "
|
||||
+ rupees(largest.amount) + " on " + largest.date + "." };
|
||||
}
|
||||
return new String[] { NO_HIT, largest == null ? "There is no " + scope + " in the window."
|
||||
: "No " + scope + " reaches " + rupees(rule.threshold) + "; the largest is " + rupees(largest.amount) + "." };
|
||||
}
|
||||
|
||||
// STRUCTURING and VELOCITY: enough transactions, each inside the rule's band, adding up to the amount, within its window
|
||||
final int days = rule.windowDays != null && rule.windowDays > 0 ? rule.windowDays : tabWindow;
|
||||
final List<Txn> inBand = new ArrayList<>();
|
||||
for (final Txn t : pool) {
|
||||
if ((rule.min == null || t.amount.compareTo(rule.min) >= 0) && (rule.max == null || t.amount.compareTo(rule.max) <= 0)) {
|
||||
inBand.add(t);
|
||||
}
|
||||
}
|
||||
final int needed = rule.minCount == null || rule.minCount <= 1 ? 1 : rule.minCount;
|
||||
int bestCount = 0;
|
||||
BigDecimal bestSum = BigDecimal.ZERO;
|
||||
for (final Txn end : inBand) {
|
||||
int count = 0;
|
||||
BigDecimal sum = BigDecimal.ZERO;
|
||||
for (final Txn t : inBand) {
|
||||
if (!t.date.isAfter(end.date) && t.date.isAfter(end.date.minusDays(days))) {
|
||||
count++;
|
||||
sum = sum.add(t.amount);
|
||||
}
|
||||
}
|
||||
if (count >= needed && sum.compareTo(rule.threshold) >= 0) {
|
||||
return new String[] { HIT, count + " " + scope + "(s) within " + days + " days add up to " + rupees(sum)
|
||||
+ " (the rule fires at " + rupees(rule.threshold) + " from " + needed + " or more)." };
|
||||
}
|
||||
if (sum.compareTo(bestSum) > 0) {
|
||||
bestSum = sum;
|
||||
bestCount = count;
|
||||
}
|
||||
}
|
||||
return new String[] { NO_HIT, inBand.isEmpty() ? "No " + scope + " inside the rule's band in the window."
|
||||
: "At most " + bestCount + " " + scope + "(s) within " + days + " days, adding up to " + rupees(bestSum)
|
||||
+ "; the rule needs " + needed + " or more adding up to " + rupees(rule.threshold) + "." };
|
||||
}
|
||||
|
||||
/** The CC scenarios written for this tab, each by its own test. */
|
||||
private String[] scenario(final CcRule rule, final Facts f, final List<Txn> txns, final BigDecimal limit,
|
||||
final BigDecimal declaredForWindow, final BigDecimal priorAverageVolume) {
|
||||
final String n = rule.name;
|
||||
if (txns.isEmpty()) {
|
||||
return new String[] { NO_HIT, "There is no activity on the account in the window." };
|
||||
}
|
||||
if (n.contains("credit turnover exceeds")) {
|
||||
return turnover(f.in, "Credits (Cr)", declaredForWindow, limit);
|
||||
}
|
||||
if (n.contains("debit turnover exceeds")) {
|
||||
return turnover(f.out, "Debits (Dr)", declaredForWindow, limit);
|
||||
}
|
||||
if (n.contains("substantially withdrawn within")) {
|
||||
return f.rapidCount > 0
|
||||
? new String[] { HIT, f.rapidCount + " credit(s) worth " + rupees(f.rapidValue) + " were mostly paid out within "
|
||||
+ RAPID_DAYS + " days." }
|
||||
: new String[] { NO_HIT, "No credit was mostly paid out again within " + RAPID_DAYS + " days." };
|
||||
}
|
||||
if (n.contains("Sudden spike")) {
|
||||
if (priorAverageVolume == null || priorAverageVolume.signum() <= 0) {
|
||||
return new String[] { NOT_CHECKED, "There is no earlier activity to compare this window with." };
|
||||
}
|
||||
final BigDecimal now = f.in.add(f.out);
|
||||
final BigDecimal multiple = now.divide(priorAverageVolume, 2, RoundingMode.HALF_UP);
|
||||
final String text = "This window moved " + rupees(now) + ", " + multiple + "x the account's own earlier average of "
|
||||
+ rupees(priorAverageVolume) + ".";
|
||||
return multiple.compareTo(SPIKE_MULTIPLE) >= 0 ? new String[] { HIT, text } : new String[] { NO_HIT, text };
|
||||
}
|
||||
if (n.contains("share of cash")) {
|
||||
final BigDecimal inShare = f.in.signum() > 0 ? f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
||||
final BigDecimal outShare = f.out.signum() > 0 ? f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP) : BigDecimal.ZERO;
|
||||
final String text = "Cash is " + percent(inShare) + " of credits and " + percent(outShare) + " of debits.";
|
||||
return inShare.compareTo(CASH_SHARE) >= 0 || outShare.compareTo(CASH_SHARE) >= 0 ? new String[] { HIT, text }
|
||||
: new String[] { NO_HIT, text };
|
||||
}
|
||||
if (n.contains("round tripping")) {
|
||||
if (limit.signum() <= 0) {
|
||||
return new String[] { NOT_CHECKED, "The account has no sanctioned limit." };
|
||||
}
|
||||
if (f.peakUse.compareTo(LIMIT_USE) >= 0 && f.clearedAfterPeak) {
|
||||
return new String[] { HIT, "The limit was drawn to " + percent(f.peakUse) + " and the account was back in credit within "
|
||||
+ CLEARED_DAYS + " days." };
|
||||
}
|
||||
return new String[] { NO_HIT, "The limit was drawn to at most " + percent(f.peakUse)
|
||||
+ (f.peakUse.compareTo(LIMIT_USE) >= 0 ? ", but the account did not return to credit within " + CLEARED_DAYS + " days." : ".") };
|
||||
}
|
||||
if (n.contains("counterparty concentration")) {
|
||||
if (f.top == null) {
|
||||
return new String[] { NOT_CHECKED, "No transaction recorded the party on the other side." };
|
||||
}
|
||||
final String text = f.top.label + " handled " + percent(f.topShare) + " of the money moved.";
|
||||
return f.topShare.compareTo(CONCENTRATION_SHARE) >= 0 && f.top.count >= 3 ? new String[] { HIT, text }
|
||||
: new String[] { NO_HIT, text };
|
||||
}
|
||||
if (n.contains("Circular movement")) {
|
||||
if (f.twoWay.isEmpty()) {
|
||||
return new String[] { NO_HIT, "No party both paid in and was paid a similar amount." };
|
||||
}
|
||||
final Total first = f.twoWay.get(0);
|
||||
return new String[] { HIT, first.label + " is on both sides: " + rupees(first.in) + " Cr and " + rupees(first.out) + " Dr." };
|
||||
}
|
||||
return new String[] { NOT_CHECKED, "Needs the customer's relationship data, which the system does not hold yet." };
|
||||
}
|
||||
|
||||
private String[] turnover(final BigDecimal amount, final String label, final BigDecimal declaredForWindow, final BigDecimal limit) {
|
||||
final BigDecimal vsDeclared = ratio(amount, declaredForWindow);
|
||||
final BigDecimal vsLimit = ratio(amount, limit);
|
||||
if (vsDeclared == null && vsLimit == null) {
|
||||
return new String[] { NOT_CHECKED, "The customer has declared no turnover and the account has no sanctioned limit." };
|
||||
}
|
||||
final boolean hit = (vsDeclared != null && vsDeclared.compareTo(TURNOVER_MULTIPLE) >= 0)
|
||||
|| (vsLimit != null && vsLimit.compareTo(TURNOVER_MULTIPLE) >= 0);
|
||||
final String text = label + " of " + rupees(amount)
|
||||
+ (vsDeclared != null ? " are " + vsDeclared + "x the declared turnover for the window" : "")
|
||||
+ (vsLimit != null ? (vsDeclared != null ? " and " : " are ") + vsLimit + "x the sanctioned limit" : "") + ".";
|
||||
return new String[] { hit ? HIT : NO_HIT, text };
|
||||
}
|
||||
|
||||
// ── Analysis ─────────────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/** One posted credit or debit, as the analysis reads it. */
|
||||
private static final class Txn {
|
||||
long id;
|
||||
LocalDate date;
|
||||
boolean credit;
|
||||
BigDecimal amount;
|
||||
String reference;
|
||||
String remarks;
|
||||
BigDecimal balanceAfter;
|
||||
boolean cash;
|
||||
String chequeNo;
|
||||
String partyAccount;
|
||||
String partyBank;
|
||||
String partyIfsc;
|
||||
String mode;
|
||||
String party;
|
||||
}
|
||||
|
||||
/** Running in / out / count for one mode or party. */
|
||||
private static final class Total {
|
||||
String label;
|
||||
String sub;
|
||||
BigDecimal in = BigDecimal.ZERO;
|
||||
BigDecimal out = BigDecimal.ZERO;
|
||||
int count;
|
||||
|
||||
BigDecimal volume() {
|
||||
return in.add(out);
|
||||
}
|
||||
|
||||
boolean unrecorded() {
|
||||
return sub == null && "Not recorded".equals(label);
|
||||
}
|
||||
}
|
||||
|
||||
/** The figures the observations and the CC scenarios are both judged on, worked out once. */
|
||||
private static final class Facts {
|
||||
BigDecimal in = BigDecimal.ZERO;
|
||||
BigDecimal out = BigDecimal.ZERO;
|
||||
BigDecimal cashIn = BigDecimal.ZERO;
|
||||
BigDecimal cashOut = BigDecimal.ZERO;
|
||||
BigDecimal volume = BigDecimal.ZERO;
|
||||
BigDecimal lowest;
|
||||
BigDecimal peakUse = BigDecimal.ZERO;
|
||||
boolean clearedAfterPeak;
|
||||
int rapidCount;
|
||||
BigDecimal rapidValue = BigDecimal.ZERO;
|
||||
Map<String, Total> byParty = new LinkedHashMap<>();
|
||||
Total top;
|
||||
BigDecimal topShare = BigDecimal.ZERO;
|
||||
List<Total> twoWay = new ArrayList<>();
|
||||
BigDecimal unrecorded = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private Facts facts(final List<Txn> txns, final BigDecimal limit) {
|
||||
final Facts f = new Facts();
|
||||
Txn lowestTxn = null;
|
||||
for (final Txn t : txns) {
|
||||
if (t.credit) {
|
||||
f.in = f.in.add(t.amount);
|
||||
if (t.cash) {
|
||||
f.cashIn = f.cashIn.add(t.amount);
|
||||
}
|
||||
} else {
|
||||
f.out = f.out.add(t.amount);
|
||||
if (t.cash) {
|
||||
f.cashOut = f.cashOut.add(t.amount);
|
||||
}
|
||||
}
|
||||
if (t.balanceAfter != null && (f.lowest == null || t.balanceAfter.compareTo(f.lowest) < 0)) {
|
||||
f.lowest = t.balanceAfter;
|
||||
lowestTxn = t;
|
||||
}
|
||||
add(f.byParty, t.party, t.party, t.partyBank, t);
|
||||
}
|
||||
f.volume = f.in.add(f.out);
|
||||
|
||||
// How hard the limit was used, and whether the account was back in credit soon after its worst point
|
||||
if (limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0) {
|
||||
f.peakUse = f.lowest.negate().divide(limit, 4, RoundingMode.HALF_UP);
|
||||
for (final Txn t : txns) {
|
||||
if (lowestTxn != null && t.id != lowestTxn.id && t.balanceAfter != null && t.balanceAfter.signum() >= 0
|
||||
&& !t.date.isBefore(lowestTxn.date) && !t.date.isAfter(lowestTxn.date.plusDays(CLEARED_DAYS))) {
|
||||
f.clearedAfterPeak = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Money in and straight out again. Each debit is set against one credit only - the earliest still unmatched that it
|
||||
// falls within RAPID_DAYS of - so one payment out is not counted as the exit of several credits.
|
||||
final List<Txn> credits = new ArrayList<>();
|
||||
final Map<Long, BigDecimal> matched = new LinkedHashMap<>();
|
||||
for (final Txn t : txns) {
|
||||
if (t.credit) {
|
||||
credits.add(t);
|
||||
matched.put(t.id, BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
for (final Txn d : txns) {
|
||||
if (d.credit) {
|
||||
continue;
|
||||
}
|
||||
BigDecimal toPlace = d.amount;
|
||||
for (final Txn c : credits) {
|
||||
if (toPlace.signum() <= 0) {
|
||||
break;
|
||||
}
|
||||
if (d.date.isBefore(c.date) || d.date.isAfter(c.date.plusDays(RAPID_DAYS))) {
|
||||
continue;
|
||||
}
|
||||
final BigDecimal room = c.amount.subtract(matched.get(c.id));
|
||||
if (room.signum() <= 0) {
|
||||
continue;
|
||||
}
|
||||
final BigDecimal placed = room.min(toPlace);
|
||||
matched.put(c.id, matched.get(c.id).add(placed));
|
||||
toPlace = toPlace.subtract(placed);
|
||||
}
|
||||
}
|
||||
for (final Txn c : credits) {
|
||||
if (matched.get(c.id).compareTo(c.amount.multiply(RAPID_SHARE)) >= 0) {
|
||||
f.rapidCount++;
|
||||
f.rapidValue = f.rapidValue.add(c.amount);
|
||||
}
|
||||
}
|
||||
|
||||
// The party handling the most, the parties on both sides, and what has no party recorded
|
||||
for (final Total t : f.byParty.values()) {
|
||||
if (t.unrecorded()) {
|
||||
f.unrecorded = f.unrecorded.add(t.volume());
|
||||
continue;
|
||||
}
|
||||
if (f.top == null || t.volume().compareTo(f.top.volume()) > 0) {
|
||||
f.top = t;
|
||||
}
|
||||
if (t.in.signum() > 0 && t.out.signum() > 0
|
||||
&& t.in.min(t.out).divide(t.in.max(t.out), 4, RoundingMode.HALF_UP).compareTo(TWO_WAY_SHARE) >= 0) {
|
||||
f.twoWay.add(t);
|
||||
}
|
||||
}
|
||||
if (f.top != null && f.volume.signum() > 0) {
|
||||
f.topShare = f.top.volume().divide(f.volume, 4, RoundingMode.HALF_UP);
|
||||
}
|
||||
return f;
|
||||
}
|
||||
|
||||
private Map<String, Object> analyse(final List<Txn> txns, final BigDecimal limit, final BigDecimal declaredForWindow) {
|
||||
final Facts f = facts(txns, limit);
|
||||
int inCount = 0;
|
||||
int outCount = 0;
|
||||
Txn largestIn = null;
|
||||
Txn largestOut = null;
|
||||
BigDecimal highest = null;
|
||||
int belowZero = 0;
|
||||
final Map<String, Total> byMode = new LinkedHashMap<>();
|
||||
final TreeMap<LocalDate, Total> byWeek = new TreeMap<>();
|
||||
final java.util.Set<LocalDate> days = new java.util.HashSet<>();
|
||||
|
||||
for (final Txn t : txns) {
|
||||
days.add(t.date);
|
||||
if (t.credit) {
|
||||
inCount++;
|
||||
if (largestIn == null || t.amount.compareTo(largestIn.amount) > 0) {
|
||||
largestIn = t;
|
||||
}
|
||||
} else {
|
||||
outCount++;
|
||||
if (largestOut == null || t.amount.compareTo(largestOut.amount) > 0) {
|
||||
largestOut = t;
|
||||
}
|
||||
}
|
||||
if (t.balanceAfter != null) {
|
||||
highest = highest == null || t.balanceAfter.compareTo(highest) > 0 ? t.balanceAfter : highest;
|
||||
if (t.balanceAfter.signum() < 0) {
|
||||
belowZero++;
|
||||
}
|
||||
}
|
||||
add(byMode, t.mode, t.mode, null, t);
|
||||
final LocalDate weekStart = t.date.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
|
||||
add(byWeek, weekStart, weekStart.toString(), null, t);
|
||||
}
|
||||
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
final Map<String, Object> summary = new LinkedHashMap<>();
|
||||
summary.put("in", f.in);
|
||||
summary.put("out", f.out);
|
||||
summary.put("net", f.in.subtract(f.out));
|
||||
summary.put("inCount", inCount);
|
||||
summary.put("outCount", outCount);
|
||||
summary.put("averageIn", inCount == 0 ? null : f.in.divide(BigDecimal.valueOf(inCount), 2, RoundingMode.HALF_UP));
|
||||
summary.put("averageOut", outCount == 0 ? null : f.out.divide(BigDecimal.valueOf(outCount), 2, RoundingMode.HALF_UP));
|
||||
summary.put("largestIn", largest(largestIn));
|
||||
summary.put("largestOut", largest(largestOut));
|
||||
summary.put("activeDays", days.size());
|
||||
summary.put("lowestBalance", f.lowest);
|
||||
summary.put("highestBalance", highest);
|
||||
summary.put("timesBelowZero", belowZero);
|
||||
summary.put("peakLimitUsedPercent", limit.signum() > 0 && f.lowest != null && f.lowest.signum() < 0
|
||||
? f.lowest.negate().multiply(BigDecimal.valueOf(100)).divide(limit, 1, RoundingMode.HALF_UP) : null);
|
||||
summary.put("cashIn", f.cashIn);
|
||||
summary.put("cashOut", f.cashOut);
|
||||
summary.put("creditsVsDeclared", ratio(f.in, declaredForWindow));
|
||||
summary.put("debitsVsDeclared", ratio(f.out, declaredForWindow));
|
||||
summary.put("declaredForWindow", declaredForWindow);
|
||||
result.put("summary", summary);
|
||||
|
||||
result.put("byMode", totals(byMode, f.volume));
|
||||
result.put("byCounterparty", totals(f.byParty, f.volume));
|
||||
result.put("timeline", totals(byWeek, f.volume));
|
||||
result.put("observations", observations(txns, f, limit, declaredForWindow));
|
||||
return result;
|
||||
}
|
||||
|
||||
private static <K> void add(final Map<K, Total> map, final K key, final String label, final String sub, final Txn t) {
|
||||
final Total total = map.computeIfAbsent(key, k -> {
|
||||
final Total fresh = new Total();
|
||||
fresh.label = label;
|
||||
fresh.sub = sub;
|
||||
return fresh;
|
||||
});
|
||||
if (t.credit) {
|
||||
total.in = total.in.add(t.amount);
|
||||
} else {
|
||||
total.out = total.out.add(t.amount);
|
||||
}
|
||||
total.count++;
|
||||
}
|
||||
|
||||
private static <K> List<Map<String, Object>> totals(final Map<K, Total> map, final BigDecimal volume) {
|
||||
final List<Total> list = new ArrayList<>(map.values());
|
||||
// Modes and parties: biggest first. The timeline map is already in date order and keeps it.
|
||||
if (!(map instanceof TreeMap)) {
|
||||
list.sort(Comparator.comparing(Total::volume).reversed());
|
||||
}
|
||||
final List<Map<String, Object>> rows = new ArrayList<>();
|
||||
for (final Total t : list) {
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("label", t.label);
|
||||
row.put("sub", t.sub);
|
||||
row.put("in", t.in);
|
||||
row.put("out", t.out);
|
||||
row.put("count", t.count);
|
||||
row.put("sharePercent", volume.signum() > 0
|
||||
? t.volume().multiply(BigDecimal.valueOf(100)).divide(volume, 1, RoundingMode.HALF_UP) : null);
|
||||
rows.add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static Map<String, Object> largest(final Txn t) {
|
||||
if (t == null) {
|
||||
return null;
|
||||
}
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("amount", t.amount);
|
||||
m.put("date", t.date.toString());
|
||||
m.put("party", t.party);
|
||||
m.put("mode", t.mode);
|
||||
return m;
|
||||
}
|
||||
|
||||
/** What stands out, with the figures behind it. Starting values only - see the class note. */
|
||||
private List<Map<String, Object>> observations(final List<Txn> txns, final Facts f, final BigDecimal limit,
|
||||
final BigDecimal declaredForWindow) {
|
||||
final List<Map<String, Object>> list = new ArrayList<>();
|
||||
|
||||
if (txns.isEmpty()) {
|
||||
list.add(observation("INFO", "No activity", "There are no credits (Cr) or debits (Dr) on this account in the window."));
|
||||
return list;
|
||||
}
|
||||
|
||||
if (declaredForWindow == null) {
|
||||
list.add(observation("INFO", "Declared turnover is missing",
|
||||
"The customer has not declared a turnover, so the activity cannot be compared with the business profile."));
|
||||
} else {
|
||||
final BigDecimal inMultiple = ratio(f.in, declaredForWindow);
|
||||
final BigDecimal outMultiple = ratio(f.out, declaredForWindow);
|
||||
if (inMultiple != null && inMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
|
||||
list.add(observation("HIGH", "Credits far above the declared turnover", "Credits (Cr) of " + rupees(f.in) + " are "
|
||||
+ inMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
|
||||
}
|
||||
if (outMultiple != null && outMultiple.compareTo(TURNOVER_MULTIPLE) >= 0) {
|
||||
list.add(observation("HIGH", "Debits far above the declared turnover", "Debits (Dr) of " + rupees(f.out) + " are "
|
||||
+ outMultiple + "x the turnover declared for this window (" + rupees(declaredForWindow) + ")."));
|
||||
}
|
||||
}
|
||||
|
||||
if (f.rapidCount > 0) {
|
||||
list.add(observation("MEDIUM", "Money paid out soon after it came in", f.rapidCount + " credit(s) (Cr) worth "
|
||||
+ rupees(f.rapidValue) + " were mostly (" + RAPID_SHARE.multiply(BigDecimal.valueOf(100)).intValue()
|
||||
+ "% or more) paid out again within " + RAPID_DAYS + " days."));
|
||||
}
|
||||
|
||||
if (f.top != null && f.topShare.compareTo(CONCENTRATION_SHARE) >= 0) {
|
||||
list.add(observation("MEDIUM", "One party handles most of the money", f.top.label
|
||||
+ (f.top.sub == null ? "" : " (" + f.top.sub + ")") + " accounts for " + percent(f.topShare) + " of everything moved ("
|
||||
+ rupees(f.top.in) + " Cr, " + rupees(f.top.out) + " Dr)."));
|
||||
}
|
||||
|
||||
for (final Total t : f.twoWay) {
|
||||
list.add(observation("HIGH", "Same party on both sides", t.label + " is on both sides: credits (Cr) of " + rupees(t.in)
|
||||
+ " came from it and debits (Dr) of " + rupees(t.out) + " went to it."));
|
||||
}
|
||||
|
||||
if (f.in.signum() > 0 && f.cashIn.divide(f.in, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
|
||||
list.add(observation("MEDIUM", "High share of cash in credits", rupees(f.cashIn) + " of " + rupees(f.in)
|
||||
+ " credited came in as cash."));
|
||||
}
|
||||
if (f.out.signum() > 0 && f.cashOut.divide(f.out, 4, RoundingMode.HALF_UP).compareTo(CASH_SHARE) >= 0) {
|
||||
list.add(observation("MEDIUM", "High share of cash in debits", rupees(f.cashOut) + " of " + rupees(f.out)
|
||||
+ " debited went out as cash."));
|
||||
}
|
||||
|
||||
if (limit.signum() > 0 && f.peakUse.compareTo(LIMIT_USE) >= 0) {
|
||||
list.add(observation("MEDIUM", "Sanctioned limit nearly used", "The account went down to " + rupees(f.lowest.negate())
|
||||
+ " Dr, which is " + percent(f.peakUse) + " of the limit."));
|
||||
}
|
||||
|
||||
if (f.volume.signum() > 0 && f.unrecorded.divide(f.volume, 4, RoundingMode.HALF_UP).compareTo(UNRECORDED_SHARE) >= 0) {
|
||||
list.add(observation("INFO", "Other side not recorded", rupees(f.unrecorded) + " of the " + rupees(f.volume)
|
||||
+ " moved has no counterparty recorded, so where it came from or went cannot be told."));
|
||||
}
|
||||
|
||||
if (list.isEmpty()) {
|
||||
list.add(observation("OK", "Nothing stands out", "None of the starting checks was triggered for this window."));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/** Why each kind of observation is worth a look, shown under it so an officer does not have to guess. */
|
||||
private static final Map<String, String> WHY = Map.of(
|
||||
"Declared turnover is missing",
|
||||
"Without a declared turnover the bank cannot tell whether this activity is normal for the customer's business.",
|
||||
"Credits far above the declared turnover",
|
||||
"Money received well beyond what the business says it turns over can mean the account is used for something other than "
|
||||
+ "the declared business.",
|
||||
"Debits far above the declared turnover",
|
||||
"Money paid out well beyond the declared turnover can mean funds are being moved on rather than spent in the business.",
|
||||
"Money paid out soon after it came in",
|
||||
"Funds that come in and go straight out within days can mean the account is only a pass-through.",
|
||||
"One party handles most of the money",
|
||||
"Depending on one party concentrates risk and can mean the account mainly serves that party.",
|
||||
"Same party on both sides",
|
||||
"Money going to a party and coming back from it can be a round trip with no real business behind it.",
|
||||
"High share of cash in credits",
|
||||
"Cash is harder to trace to its source than a bank transfer, so a high share of it deserves a look.",
|
||||
"High share of cash in debits",
|
||||
"Large cash withdrawals leave no trail of where the money went.",
|
||||
"Sanctioned limit nearly used",
|
||||
"An account drawn close to its sanctioned limit has little room left and may be under strain or being used up fast.",
|
||||
"Other side not recorded",
|
||||
"Without the party on the other side the bank cannot tell where the money came from or went to.");
|
||||
|
||||
private static Map<String, Object> observation(final String level, final String title, final String detail) {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("level", level);
|
||||
m.put("title", title);
|
||||
m.put("detail", detail);
|
||||
m.put("why", WHY.get(title));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static String rupees(final BigDecimal value) {
|
||||
return "₹" + String.format("%,.2f", value);
|
||||
}
|
||||
|
||||
private static String percent(final BigDecimal fraction) {
|
||||
return fraction.multiply(BigDecimal.valueOf(100)).setScale(1, RoundingMode.HALF_UP) + "%";
|
||||
}
|
||||
|
||||
// ── Mode and party ───────────────────────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How a payment moved. The bank's payment types are only Cash and Transfer, so a cheque, a demand draft, NEFT, RTGS, IMPS,
|
||||
* UPI or an internal transfer is told from the cheque number the payment recorded and from the words in its payment type
|
||||
* and remarks. Where nothing says, it is the payment type itself.
|
||||
*/
|
||||
private static String modeOf(final boolean cash, final String paymentType, final String chequeNo, final String remarks) {
|
||||
if (cash) {
|
||||
return "Cash";
|
||||
}
|
||||
final String text = ((paymentType == null ? "" : paymentType) + " " + (remarks == null ? "" : remarks)).toUpperCase();
|
||||
if (hasWord(text, "RTGS")) {
|
||||
return "RTGS";
|
||||
}
|
||||
if (hasWord(text, "NEFT")) {
|
||||
return "NEFT";
|
||||
}
|
||||
if (hasWord(text, "IMPS")) {
|
||||
return "IMPS";
|
||||
}
|
||||
if (hasWord(text, "UPI")) {
|
||||
return "UPI";
|
||||
}
|
||||
if (hasWord(text, "DEMAND DRAFT") || hasWord(text, "DD")) {
|
||||
return "Demand Draft";
|
||||
}
|
||||
if (chequeNo != null && !chequeNo.isBlank() || hasWord(text, "CHEQUE") || hasWord(text, "CHECK")) {
|
||||
return "Cheque";
|
||||
}
|
||||
if (hasWord(text, "INTERNAL TRANSFER")) {
|
||||
return "Internal transfer";
|
||||
}
|
||||
return paymentType == null || paymentType.isBlank() ? "Not recorded" : paymentType;
|
||||
}
|
||||
|
||||
/** Whether {@code word} stands alone in {@code text}, so "UPI" is not found inside some other word. */
|
||||
private static boolean hasWord(final String text, final String word) {
|
||||
return java.util.regex.Pattern.compile("(^|[^A-Z0-9])" + java.util.regex.Pattern.quote(word) + "([^A-Z0-9]|$)").matcher(text)
|
||||
.find();
|
||||
}
|
||||
|
||||
/** The party on the other side, named by the account the payment recorded; "Not recorded" when it recorded none. */
|
||||
private static String partyOf(final Txn t) {
|
||||
return t.partyAccount == null || t.partyAccount.isBlank() ? "Not recorded" : t.partyAccount;
|
||||
}
|
||||
|
||||
private static BigDecimal scaledToWindow(final BigDecimal declaredMonthly, final int window) {
|
||||
return declaredMonthly == null ? null
|
||||
: declaredMonthly.multiply(BigDecimal.valueOf(window)).divide(BigDecimal.valueOf(30), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
|
||||
private static BigDecimal money(final Object value) {
|
||||
return value == null ? BigDecimal.ZERO : new BigDecimal(value.toString());
|
||||
}
|
||||
|
||||
-67
@@ -1,67 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.api;
|
||||
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.CacheControl;
|
||||
import jakarta.ws.rs.core.Context;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import jakarta.ws.rs.sse.Sse;
|
||||
import jakarta.ws.rs.sse.SseEventSink;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsReadPlatformService;
|
||||
import org.apache.fineract.portfolio.approvals.service.ApprovalCountsStreamService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* How many requests wait on every Approval Center tab, for the badge on each tab and the Total on the Approvals menu.
|
||||
* Numbers only: opening a tab still loads that tab's own list. The counting is in
|
||||
* {@link ApprovalCountsReadPlatformService}; the live stream is in {@link ApprovalCountsStreamService}.
|
||||
*/
|
||||
@Path("/v1/approvals/counts")
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public class ApprovalCountsApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final ApprovalCountsReadPlatformService readService;
|
||||
private final ApprovalCountsStreamService streamService;
|
||||
|
||||
/**
|
||||
* The counts as they are now, answered at once. {@code fresh=true} skips the few seconds the last answer is held
|
||||
* for. Screens that can stream use {@link #stream} instead; this is for a one-off read and as a fallback.
|
||||
*/
|
||||
@GET
|
||||
public Response counts(@QueryParam("fresh") @DefaultValue("false") final boolean fresh) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final CacheControl noStore = new CacheControl();
|
||||
noStore.setNoStore(true);
|
||||
return Response.ok(this.readService.retrieveHeld(user, fresh).toJson()).cacheControl(noStore).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-Sent Events: the connection stays open and a {@code counts} event is written the moment the numbers change,
|
||||
* so a screen neither polls nor reloads. The first event carries the numbers as they stand.
|
||||
*/
|
||||
@GET
|
||||
@Path("stream")
|
||||
@Produces(MediaType.SERVER_SENT_EVENTS)
|
||||
public void stream(@Context final SseEventSink sink, @Context final Sse sse, @Context final HttpServletResponse response) {
|
||||
// Ask reverse proxies not to hold the events back.
|
||||
response.setHeader("X-Accel-Buffering", "no");
|
||||
response.setHeader("Cache-Control", "no-cache, no-store");
|
||||
try {
|
||||
this.streamService.open(this.readService.planFor(this.context.authenticatedUser()), sink, sse);
|
||||
} catch (final RuntimeException e) {
|
||||
sink.close();
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.data;
|
||||
|
||||
/**
|
||||
* One Approval Center tab and the question that counts what waits on it.
|
||||
*
|
||||
* @param key
|
||||
* the tab's route segment, which is also the key its badge reads on the screen
|
||||
* @param permission
|
||||
* the permission the tab is shown under (READ_* is also met by ALL_FUNCTIONS_READ); null means every caller sees
|
||||
* the tab
|
||||
* @param sql
|
||||
* a scalar sub-select answering "how many", written with the named parameters of {@link ApprovalCountsPlan}
|
||||
*/
|
||||
public record ApprovalCounter(String key, String permission, String sql) {}
|
||||
-27
@@ -1,27 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.data;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* What is waiting for approval: the total, the number for each tab that has anything, and a version that is equal
|
||||
* exactly when the numbers are, which is how a change is told from a repeat.
|
||||
*/
|
||||
public record ApprovalCountsData(long total, Map<String, Long> counts, String version) {
|
||||
|
||||
private static final Gson GSON = new Gson();
|
||||
|
||||
public static ApprovalCountsData of(final Map<String, Long> counts) {
|
||||
final long total = counts.values().stream().mapToLong(Long::longValue).sum();
|
||||
return new ApprovalCountsData(total, counts, Integer.toHexString(counts.hashCode()));
|
||||
}
|
||||
|
||||
public String toJson() {
|
||||
final Map<String, Object> json = new LinkedHashMap<>();
|
||||
json.put("total", this.total);
|
||||
json.put("counts", this.counts);
|
||||
json.put("version", this.version);
|
||||
return GSON.toJson(json);
|
||||
}
|
||||
}
|
||||
-22
@@ -1,22 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.data;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
|
||||
/**
|
||||
* What one caller is counted for: the tabs they may see and the values (office scope, user, checker rights) those
|
||||
* tabs' queries are asked with. It is worked out on the request thread, because it reads the user's roles, and can then
|
||||
* be used from any thread.
|
||||
*
|
||||
* @param tenant
|
||||
* the tenant the counts are read from
|
||||
* @param visible
|
||||
* the tabs this caller may see, in the order the Approval Center shows them
|
||||
* @param params
|
||||
* the named parameters the tabs' queries use
|
||||
* @param group
|
||||
* equal for callers who are counted identically, so they can share one query
|
||||
*/
|
||||
public record ApprovalCountsPlan(FineractPlatformTenant tenant, List<ApprovalCounter> visible, MapSqlParameterSource params,
|
||||
String group) {}
|
||||
-122
@@ -1,122 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.service;
|
||||
|
||||
import java.util.List;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
||||
|
||||
/**
|
||||
* Every Approval Center tab that carries a badge, and the question that counts what waits on it. Each question is the
|
||||
* one the tab asks of its own API (same table, same status, same office scope, same entries left out). To badge a new
|
||||
* tab, add one line here and one badge on the screen.
|
||||
*
|
||||
* The queries use these named parameters, supplied per caller: {@code :h} (office hierarchy pattern), {@code :ho} (head
|
||||
* office), {@code :uid} (user) and {@code :superChecker} (may check every maker-checker command).
|
||||
*/
|
||||
public final class ApprovalCounterRegistry {
|
||||
|
||||
private ApprovalCounterRegistry() {}
|
||||
|
||||
/** Entities whose maker-checker requests are shown under Product Approval, not in the Checker Inbox. */
|
||||
private static final String PRODUCT_ENTITIES = "'LOANPRODUCT','SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT','CHARGE'";
|
||||
|
||||
/** Product edits that are staged in m_saving_product_temp, so Product Approval already lists them from there. */
|
||||
private static final String STAGED_PRODUCT_ENTITIES = "'SAVINGSPRODUCT','CURRENTPRODUCT','SHAREPRODUCT',"
|
||||
+ "'FIXEDDEPOSITPRODUCT','RECURRINGDEPOSITPRODUCT'";
|
||||
|
||||
/** Accounts whose UPDATE is staged for Pending Account Edits, so the Checker Inbox leaves it out. */
|
||||
private static final String STAGED_ACCOUNT_ENTITIES = "'SAVINGSACCOUNT','CURRENTACCOUNT'";
|
||||
|
||||
private static final String DDS_ENTITIES = "'PARTY','AGENT','PARTYTRANSACTION','DDSACCOUNTTYPE','COMMISSIONSLAB',"
|
||||
+ "'DDSBATCH','DDSCOLLECTIONENTRY'";
|
||||
|
||||
/** Maker-checker commands awaiting a checker, limited to the offices and checker rights the caller has. */
|
||||
private static final String MC_PENDING = "FROM m_portfolio_command_source aud WHERE aud.status = 2 "
|
||||
+ "AND (:ho OR EXISTS (SELECT 1 FROM m_office o2 WHERE o2.id = aud.office_id AND o2.hierarchy LIKE :h)) "
|
||||
+ "AND (:superChecker OR EXISTS (SELECT 1 FROM m_permission p JOIN m_role_permission rp ON rp.permission_id = p.id "
|
||||
+ "JOIN m_appuser_role ur ON ur.role_id = rp.role_id AND ur.appuser_id = :uid "
|
||||
+ "WHERE p.code LIKE '%\\_CHECKER' AND REPLACE(p.action_name, '_CHECKER', '') = aud.action_name "
|
||||
+ "AND p.entity_name = aud.entity_name)) ";
|
||||
|
||||
/** Where the person who raised a loan or account sits, for the office scope the tab applies. */
|
||||
private static final String CLIENT_OFFICE = "EXISTS (SELECT 1 FROM m_office o WHERE o.id = COALESCE(c.office_id, g.office_id) "
|
||||
+ "AND o.hierarchy LIKE :h)";
|
||||
|
||||
/** The one tab whose list is open to managers and head cashiers only. */
|
||||
public static final String DISBURSEMENT_REQUESTS = "disbursement-requests";
|
||||
|
||||
/** The tabs, in the order the Approval Center shows them. A null permission means every caller sees the tab. */
|
||||
public static final List<ApprovalCounter> ALL = List.of(
|
||||
// ── Fineract's own maker-checker and pending lists ────────────────────────────
|
||||
new ApprovalCounter("checker-inbox", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) " + MC_PENDING + "AND upper(aud.entity_name) NOT IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_ACCOUNT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE')"),
|
||||
new ApprovalCounter("client-approval", "READ_CLIENT",
|
||||
"SELECT count(*) FROM m_client c WHERE c.status_enum = 100 "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = c.office_id AND o.hierarchy LIKE :h)"),
|
||||
new ApprovalCounter("loan-approval", "READ_LOAN",
|
||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
||||
+ "WHERE l.loan_status_id = 100 AND " + CLIENT_OFFICE),
|
||||
new ApprovalCounter("loan-disbursal", "READ_LOAN",
|
||||
"SELECT count(*) FROM m_loan l LEFT JOIN m_client c ON c.id = l.client_id LEFT JOIN m_group g ON g.id = l.group_id "
|
||||
+ "WHERE l.loan_status_id = 200 AND " + CLIENT_OFFICE),
|
||||
new ApprovalCounter("reschedule-loan", "RESCHEDULE_LOAN",
|
||||
"SELECT count(*) FROM m_loan_reschedule_request WHERE status_enum = 100"),
|
||||
new ApprovalCounter("pending-account", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_savings_account s LEFT JOIN m_client c ON c.id = s.client_id "
|
||||
+ "LEFT JOIN m_group g ON g.id = s.group_id WHERE s.status_enum = 100 "
|
||||
+ "AND COALESCE(s.terminate_true, false) = false AND " + CLIENT_OFFICE
|
||||
+ ") + (SELECT count(*) FROM m_current_account ca WHERE ca.status_enum = 100 "
|
||||
+ "AND COALESCE(ca.terminate_true, false) = false "
|
||||
+ "AND EXISTS (SELECT 1 FROM m_office o WHERE o.id = ca.office_id AND o.hierarchy LIKE :h))"),
|
||||
new ApprovalCounter("pending-share-accounts", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_account WHERE status_enum = 100"),
|
||||
new ApprovalCounter("product-approval", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_saving_product_temp WHERE status = 'PENDING' "
|
||||
+ "AND product_type IN ('SAVINGS','CURRENT','SHARE','FD','RD')) + (SELECT count(*) " + MC_PENDING
|
||||
+ "AND upper(aud.entity_name) IN (" + PRODUCT_ENTITIES + ") "
|
||||
+ "AND NOT (upper(aud.entity_name) IN (" + STAGED_PRODUCT_ENTITIES + ") AND upper(aud.action_name) = 'UPDATE'))"),
|
||||
new ApprovalCounter("pending-savings-account-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT (SELECT count(*) FROM m_savings_account_pending_edit WHERE status = 'PENDING') "
|
||||
+ "+ (SELECT count(*) FROM m_current_account_pending_edit WHERE status = 'PENDING') "
|
||||
+ "+ (SELECT count(*) FROM m_share_account_pending_edit WHERE status = 'PENDING')"),
|
||||
new ApprovalCounter("pending-client-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_client_pending_edit pe JOIN m_client c ON c.id = pe.client_id WHERE pe.status = 'PENDING'"),
|
||||
new ApprovalCounter("pending-user-edits", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_user_pending_request WHERE status = 'PENDING'"),
|
||||
// ── Requests raised by clerks, cashiers and managers ──────────────────────────
|
||||
new ApprovalCounter("withdrawal-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_withdrawal_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("cash-transfers", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_cash_transfer_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("voucher-corrections", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_voucher_correction WHERE status IN ('UNPASS_PENDING','CORRECTION_PENDING')"),
|
||||
new ApprovalCounter("share-withdrawal-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_share_withdrawal_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dividend-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_product_dividend_pay_out p JOIN m_share_product sp ON sp.id = p.product_id "
|
||||
+ "WHERE p.status = 100"),
|
||||
new ApprovalCounter("share-addition-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_share_account_transactions t JOIN m_share_account sa ON sa.id = t.account_id "
|
||||
+ "JOIN m_client c ON c.id = sa.client_id JOIN m_share_product sp ON sp.id = sa.product_id "
|
||||
+ "WHERE t.is_active = true AND t.status_enum = 100 AND t.type_enum = 500"),
|
||||
new ApprovalCounter("deposit-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_cashier_deposit_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter(DISBURSEMENT_REQUESTS, "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_disbursement_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dds-approvals", null,
|
||||
"SELECT count(*) FROM m_portfolio_command_source c JOIN m_permission p "
|
||||
+ "ON p.code = c.action_name || '_' || c.entity_name AND p.can_maker_checker = true "
|
||||
+ "WHERE c.status = 2 AND c.entity_name IN (" + DDS_ENTITIES + ")"),
|
||||
new ApprovalCounter("chequebook-requests", "READ_CHEQUEBOOKPENDINGREQUEST",
|
||||
"SELECT count(*) FROM m_chequebook_pending_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("closure-requests", "ALL_FUNCTIONS_READ",
|
||||
"SELECT count(*) FROM m_clerk_closure_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dd-issue-approvals", null,
|
||||
"SELECT count(*) FROM m_sponsor_dd_issue_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dd-reprint-approvals", null,
|
||||
"SELECT count(*) FROM m_sponsor_dd_reprint_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("dd-revoke-approvals", null,
|
||||
"SELECT count(*) FROM m_sponsor_dd_revoke_request WHERE status = 'PENDING'"),
|
||||
new ApprovalCounter("risk-change-approvals", null,
|
||||
"SELECT count(*) FROM m_client_risk_change_request WHERE status = 'PENDING'"));
|
||||
}
|
||||
-24
@@ -1,24 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.service;
|
||||
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
|
||||
/** Reads how many requests wait on each Approval Center tab, in one statement. */
|
||||
public interface ApprovalCountsReadPlatformService {
|
||||
|
||||
/**
|
||||
* Works out what the caller is counted for. Call it on the request thread: it reads the user's roles and the
|
||||
* tenant of the current request.
|
||||
*/
|
||||
ApprovalCountsPlan planFor(AppUser user);
|
||||
|
||||
/** Counts now, for the tabs in the plan. The plan's tenant must be the current tenant of the calling thread. */
|
||||
ApprovalCountsData retrieve(ApprovalCountsPlan plan);
|
||||
|
||||
/**
|
||||
* Counts for the caller, from the answer held for a few seconds when there is one, so many screens asking together
|
||||
* cost the database one query. {@code fresh} skips the held answer.
|
||||
*/
|
||||
ApprovalCountsData retrieveHeld(AppUser user, boolean fresh);
|
||||
}
|
||||
-114
@@ -1,114 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.service;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCounter;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* All the visible tabs' questions go to the database as one statement (one scalar sub-select per tab), so the cost is
|
||||
* one connection and one plan instead of a call per tab. A tab the caller may not see is simply not asked.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalCountsReadPlatformServiceImpl implements ApprovalCountsReadPlatformService {
|
||||
|
||||
/** Screens asking together read one answer instead of each running the statement. */
|
||||
private static final long HOLD_MILLIS = 3_000L;
|
||||
|
||||
private record Held(long at, ApprovalCountsData data) {}
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final Map<String, Held> held = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public ApprovalCountsPlan planFor(final AppUser user) {
|
||||
final List<ApprovalCounter> visible = new ArrayList<>();
|
||||
for (final ApprovalCounter counter : ApprovalCounterRegistry.ALL) {
|
||||
if (isVisible(user, counter)) {
|
||||
visible.add(counter);
|
||||
}
|
||||
}
|
||||
final String hierarchy = user.getOffice().getHierarchy();
|
||||
final boolean superChecker = !user.hasNotPermissionForAnyOf("ALL_FUNCTIONS", "CHECKER_SUPER_USER");
|
||||
final MapSqlParameterSource params = new MapSqlParameterSource().addValue("h", hierarchy + "%")
|
||||
.addValue("ho", ".".equals(hierarchy)).addValue("uid", user.getId()).addValue("superChecker", superChecker);
|
||||
final FineractPlatformTenant tenant = ThreadLocalContextUtil.getTenant();
|
||||
// Callers counted the same way share one query: only a limited checker's numbers depend on who they are.
|
||||
final String group = tenant.getTenantIdentifier() + '|' + hierarchy + '|' + superChecker + '|'
|
||||
+ (superChecker ? "" : user.getId()) + '|' + visible.stream().map(ApprovalCounter::key).collect(Collectors.joining(","));
|
||||
return new ApprovalCountsPlan(tenant, visible, params, group);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalCountsData retrieve(final ApprovalCountsPlan plan) {
|
||||
final Map<String, Long> counts = new LinkedHashMap<>();
|
||||
final List<ApprovalCounter> visible = plan.visible();
|
||||
if (!visible.isEmpty()) {
|
||||
final StringBuilder sql = new StringBuilder("SELECT ");
|
||||
for (int i = 0; i < visible.size(); i++) {
|
||||
sql.append(i == 0 ? "" : ", ").append('(').append(visible.get(i).sql()).append(") AS c").append(i);
|
||||
}
|
||||
final Map<String, Object> row = new NamedParameterJdbcTemplate(this.jdbcTemplate).queryForMap(sql.toString(),
|
||||
plan.params());
|
||||
for (int i = 0; i < visible.size(); i++) {
|
||||
final long n = row.get("c" + i) instanceof Number number ? number.longValue() : 0L;
|
||||
if (n > 0) {
|
||||
counts.put(visible.get(i).key(), n);
|
||||
}
|
||||
}
|
||||
}
|
||||
return ApprovalCountsData.of(counts);
|
||||
}
|
||||
|
||||
@Override
|
||||
public ApprovalCountsData retrieveHeld(final AppUser user, final boolean fresh) {
|
||||
final ApprovalCountsPlan plan = planFor(user);
|
||||
final String holdKey = plan.tenant().getTenantIdentifier() + ':' + user.getId();
|
||||
final long now = System.currentTimeMillis();
|
||||
final Held recent = this.held.get(holdKey);
|
||||
if (!fresh && recent != null && now - recent.at() < HOLD_MILLIS) {
|
||||
return recent.data();
|
||||
}
|
||||
final ApprovalCountsData data = retrieve(plan);
|
||||
this.held.put(holdKey, new Held(now, data));
|
||||
if (this.held.size() > 500) {
|
||||
this.held.entrySet().removeIf(e -> now - e.getValue().at() >= HOLD_MILLIS);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
/** The tab's own visibility rule: its permission (READ_* is also met by ALL_FUNCTIONS_READ) and, for one tab, its role. */
|
||||
private boolean isVisible(final AppUser user, final ApprovalCounter counter) {
|
||||
if (counter.permission() != null) {
|
||||
final boolean allowed = counter.permission().startsWith("READ_")
|
||||
? user.hasAnyPermission(counter.permission(), "ALL_FUNCTIONS_READ")
|
||||
: user.hasAnyPermission(counter.permission());
|
||||
if (!allowed) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return !ApprovalCounterRegistry.DISBURSEMENT_REQUESTS.equals(counter.key()) || isChecker(user);
|
||||
}
|
||||
|
||||
private boolean isChecker(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return true;
|
||||
}
|
||||
return user.getRoles().stream().map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
}
|
||||
}
|
||||
-145
@@ -1,145 +0,0 @@
|
||||
package org.apache.fineract.portfolio.approvals.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import jakarta.ws.rs.sse.OutboundSseEvent;
|
||||
import jakarta.ws.rs.sse.Sse;
|
||||
import jakarta.ws.rs.sse.SseEventSink;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsData;
|
||||
import org.apache.fineract.portfolio.approvals.data.ApprovalCountsPlan;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Keeps the Approvals badge live. A screen listens on a Server-Sent Events stream and is written to the moment its
|
||||
* numbers change, so it neither polls nor reloads.
|
||||
*
|
||||
* One shared check, every {@value #CHECK_MILLIS} ms, serves every open screen, and screens counted the same way share
|
||||
* its query. A comment line every {@value #PING_MILLIS} ms keeps proxies from closing an idle connection and tells the
|
||||
* server when a screen has gone. A stream ends after {@value #LIFETIME_MILLIS} ms so the screen reconnects with a clean
|
||||
* connection, which bounds what a browser accumulates on one.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ApprovalCountsStreamService {
|
||||
|
||||
/** How often the open screens are looked at: the longest a change waits before it is pushed. */
|
||||
private static final long CHECK_MILLIS = 1_000L;
|
||||
|
||||
private static final long PING_MILLIS = 15_000L;
|
||||
|
||||
private static final long LIFETIME_MILLIS = 300_000L;
|
||||
|
||||
/** A screen listening, and the version of the numbers it was last sent. */
|
||||
private static final class Listener {
|
||||
|
||||
private final ApprovalCountsPlan plan;
|
||||
private final SseEventSink sink;
|
||||
private final Sse sse;
|
||||
private final long expiresAt;
|
||||
private volatile String version;
|
||||
private volatile long lastSent;
|
||||
|
||||
private Listener(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse, final long expiresAt) {
|
||||
this.plan = plan;
|
||||
this.sink = sink;
|
||||
this.sse = sse;
|
||||
this.expiresAt = expiresAt;
|
||||
}
|
||||
}
|
||||
|
||||
private final ApprovalCountsReadPlatformService readService;
|
||||
private final Set<Listener> listeners = ConcurrentHashMap.newKeySet();
|
||||
private final ScheduledExecutorService ticker = Executors.newSingleThreadScheduledExecutor(runnable -> {
|
||||
final Thread thread = new Thread(runnable, "approval-counts-stream");
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
});
|
||||
|
||||
@PostConstruct
|
||||
void startTicker() {
|
||||
this.ticker.scheduleWithFixedDelay(this::tick, CHECK_MILLIS, CHECK_MILLIS, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
void stopTicker() {
|
||||
this.ticker.shutdownNow();
|
||||
this.listeners.forEach(this::close);
|
||||
}
|
||||
|
||||
/** Sends the numbers as they stand, then keeps the screen informed. Call it on the request thread. */
|
||||
public void open(final ApprovalCountsPlan plan, final SseEventSink sink, final Sse sse) {
|
||||
final Listener listener = new Listener(plan, sink, sse, System.currentTimeMillis() + LIFETIME_MILLIS);
|
||||
try {
|
||||
publish(listener, this.readService.retrieve(plan));
|
||||
} catch (final RuntimeException e) {
|
||||
sink.close();
|
||||
throw e;
|
||||
}
|
||||
this.listeners.add(listener);
|
||||
}
|
||||
|
||||
/** Looks at the numbers for every open screen and writes to the ones whose numbers moved. */
|
||||
private void tick() {
|
||||
if (this.listeners.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
final long now = System.currentTimeMillis();
|
||||
final Map<String, ApprovalCountsData> computed = new HashMap<>();
|
||||
for (final Listener listener : this.listeners) {
|
||||
try {
|
||||
if (listener.sink.isClosed() || now >= listener.expiresAt) {
|
||||
close(listener);
|
||||
continue;
|
||||
}
|
||||
ApprovalCountsData data = computed.get(listener.plan.group());
|
||||
if (data == null) {
|
||||
ThreadLocalContextUtil.setTenant(listener.plan.tenant());
|
||||
data = this.readService.retrieve(listener.plan);
|
||||
computed.put(listener.plan.group(), data);
|
||||
}
|
||||
if (!data.version().equals(listener.version)) {
|
||||
publish(listener, data);
|
||||
} else if (now - listener.lastSent >= PING_MILLIS) {
|
||||
listener.lastSent = now;
|
||||
send(listener, listener.sse.newEventBuilder().comment("ping").build());
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
close(listener);
|
||||
} finally {
|
||||
ThreadLocalContextUtil.clearTenant();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void publish(final Listener listener, final ApprovalCountsData data) {
|
||||
listener.version = data.version();
|
||||
listener.lastSent = System.currentTimeMillis();
|
||||
send(listener, listener.sse.newEvent("counts", data.toJson()));
|
||||
}
|
||||
|
||||
private void send(final Listener listener, final OutboundSseEvent event) {
|
||||
listener.sink.send(event).whenComplete((sent, failure) -> {
|
||||
if (failure != null) {
|
||||
close(listener);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private void close(final Listener listener) {
|
||||
this.listeners.remove(listener);
|
||||
try {
|
||||
listener.sink.close();
|
||||
} catch (final RuntimeException ignored) {
|
||||
// The screen is already gone.
|
||||
}
|
||||
}
|
||||
}
|
||||
+319
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* 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.cic.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.DefaultValue;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cic.service.CicAlertService;
|
||||
import org.apache.fineract.portfolio.cic.service.CicCatalog;
|
||||
import org.apache.fineract.portfolio.cic.service.CicConfigService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* CIC / credit bureau: bureau whitelist and configuration, API mapping, and the enquiry alerts the CICs send when one of
|
||||
* the bank's customers is enquired by another lender.
|
||||
*
|
||||
* Permissions: READ_CICBUREAU / UPDATE_CICBUREAU (configuration and mapping), READ_CICALERT (dashboard and register),
|
||||
* UPDATE_CICALERT (work an alert), FETCH_CICALERT (fetch now / import a file).
|
||||
*/
|
||||
@Path("/v1/cic")
|
||||
@Component
|
||||
@Tag(name = "CIC Credit Bureau", description = "Credit bureau (CIBIL, Experian, Equifax, CRIF High Mark) whitelist, API mapping and enquiry alerts.")
|
||||
@RequiredArgsConstructor
|
||||
public class CicApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final CicConfigService configService;
|
||||
private final CicAlertService alertService;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── Catalog ───────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("catalog")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String catalog() {
|
||||
context.authenticatedUser();
|
||||
final Map<String, Object> c = new LinkedHashMap<>();
|
||||
c.put("environments", CicCatalog.ENVIRONMENTS);
|
||||
c.put("authTypes", CicCatalog.AUTH_TYPES);
|
||||
c.put("endpointTypes", CicCatalog.ENDPOINT_TYPES);
|
||||
c.put("httpMethods", CicCatalog.HTTP_METHODS);
|
||||
c.put("cbsFields", CicCatalog.CBS_FIELDS);
|
||||
c.put("resultFields", CicCatalog.RESULT_FIELDS);
|
||||
c.put("rootFields", CicCatalog.ROOT_FIELDS);
|
||||
c.put("transforms", CicCatalog.TRANSFORMS);
|
||||
c.put("alertStatuses", CicCatalog.ALERT_STATUSES);
|
||||
c.put("matchTypes", CicCatalog.MATCH_TYPES);
|
||||
return gson.toJson(c);
|
||||
}
|
||||
|
||||
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("bureaus")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String bureaus(@QueryParam("whitelistedOnly") @DefaultValue("false") final boolean whitelistedOnly) {
|
||||
requireAny("READ_CICBUREAU", "READ_CICALERT");
|
||||
return gson.toJson(configService.listBureaus(whitelistedOnly));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("bureaus/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String bureau(@PathParam("id") final Long id) {
|
||||
require("READ_CICBUREAU");
|
||||
return gson.toJson(configService.getBureau(id));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("bureaus/{id}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String updateBureau(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("UPDATE_CICBUREAU");
|
||||
return gson.toJson(configService.updateBureau(id, json(body), user));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("bureaus/{id}/whitelist")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String whitelist(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("UPDATE_CICBUREAU");
|
||||
final JsonObject in = json(body);
|
||||
final boolean whitelisted = in.has("whitelisted") && !in.get("whitelisted").isJsonNull() && in.get("whitelisted").getAsBoolean();
|
||||
return gson.toJson(configService.setWhitelisted(id, whitelisted, user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bureaus/{id}/test-connection")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String testConnection(@PathParam("id") final Long id) {
|
||||
require("UPDATE_CICBUREAU");
|
||||
return gson.toJson(configService.testConnection(id));
|
||||
}
|
||||
|
||||
// ── API mapping ───────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("bureaus/{id}/endpoints/{type}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String endpoint(@PathParam("id") final Long id, @PathParam("type") final String type) {
|
||||
require("READ_CICBUREAU");
|
||||
return gson.toJson(configService.getEndpoint(id, type));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("bureaus/{id}/endpoints/{type}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String saveEndpoint(@PathParam("id") final Long id, @PathParam("type") final String type, final String body) {
|
||||
final AppUser user = require("UPDATE_CICBUREAU");
|
||||
return gson.toJson(configService.saveEndpoint(id, type, json(body), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bureaus/{id}/preview")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String preview(@PathParam("id") final Long id, final String body) {
|
||||
require("READ_CICBUREAU");
|
||||
return gson.toJson(alertService.preview(id, json(body)));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bureaus/{id}/parse-test")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String parseTest(@PathParam("id") final Long id, final String body) {
|
||||
require("READ_CICBUREAU");
|
||||
return gson.toJson(alertService.parseTest(id, json(body)));
|
||||
}
|
||||
|
||||
// ── Fetch / import ────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("bureaus/{id}/fetch")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String fetch(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("FETCH_CICALERT");
|
||||
final JsonObject in = json(body);
|
||||
return gson.toJson(alertService.fetch(id, text(in, "fromDate"), text(in, "toDate"), "MANUAL", user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("bureaus/{id}/import")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String importFile(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("FETCH_CICALERT");
|
||||
return gson.toJson(alertService.importFile(id, json(body), user));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("fetch-runs")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String fetchRuns(@QueryParam("bureauId") final Long bureauId, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||
return gson.toJson(alertService.fetchRuns(bureauId, Math.max(0, offset), clamp(limit)));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("fetch-runs/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String fetchRun(@PathParam("id") final Long id) {
|
||||
requireAny("READ_CICALERT", "READ_CICBUREAU");
|
||||
return gson.toJson(alertService.fetchRun(id, true));
|
||||
}
|
||||
|
||||
// ── Alerts ────────────────────────────────────────────────────────────────
|
||||
|
||||
@GET
|
||||
@Path("dashboard")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String dashboard(@QueryParam("days") @DefaultValue("30") final int days) {
|
||||
require("READ_CICALERT");
|
||||
return gson.toJson(alertService.dashboard(Math.max(1, Math.min(365, days))));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("alerts")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String alerts(@QueryParam("status") final String status, @QueryParam("match") final String match,
|
||||
@QueryParam("clientId") final String clientId, @QueryParam("bureauId") final String bureauId,
|
||||
@QueryParam("officeId") final String officeId, @QueryParam("loanType") final String loanType,
|
||||
@QueryParam("institutionType") final String institutionType, @QueryParam("fromDate") final String fromDate,
|
||||
@QueryParam("toDate") final String toDate, @QueryParam("minAmount") final String minAmount,
|
||||
@QueryParam("search") final String search, @QueryParam("offset") @DefaultValue("0") final int offset,
|
||||
@QueryParam("limit") @DefaultValue("20") final int limit) {
|
||||
require("READ_CICALERT");
|
||||
final JsonObject f = new JsonObject();
|
||||
f.addProperty("status", status);
|
||||
f.addProperty("match", match);
|
||||
f.addProperty("clientId", clientId);
|
||||
f.addProperty("bureauId", bureauId);
|
||||
f.addProperty("officeId", officeId);
|
||||
f.addProperty("loanType", loanType);
|
||||
f.addProperty("institutionType", institutionType);
|
||||
f.addProperty("fromDate", fromDate);
|
||||
f.addProperty("toDate", toDate);
|
||||
f.addProperty("minAmount", minAmount);
|
||||
f.addProperty("search", search);
|
||||
return gson.toJson(alertService.list(f, Math.max(0, offset), Math.max(1, Math.min(5000, limit))));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("alerts/options")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String alertOptions() {
|
||||
require("READ_CICALERT");
|
||||
return gson.toJson(alertService.filterOptions());
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("alerts/{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String alert(@PathParam("id") final Long id) {
|
||||
require("READ_CICALERT");
|
||||
return gson.toJson(alertService.get(id));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("alerts/{id}/status")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String updateStatus(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("UPDATE_CICALERT");
|
||||
return gson.toJson(alertService.updateStatus(id, json(body), user));
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("alerts/{id}/client")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String linkClient(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = require("UPDATE_CICALERT");
|
||||
return gson.toJson(alertService.linkClient(id, json(body), user));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("alerts/mark-seen")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String markSeen(final String body) {
|
||||
final AppUser user = require("UPDATE_CICALERT");
|
||||
return gson.toJson(alertService.markSeen(json(body), user));
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private AppUser require(final String permission) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
user.validateHasPermissionTo(permission);
|
||||
return user;
|
||||
}
|
||||
|
||||
private AppUser requireAny(final String... permissions) {
|
||||
final AppUser user = context.authenticatedUser();
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS") || user.hasAnyPermission(permissions)) {
|
||||
return user;
|
||||
}
|
||||
throw new NoAuthorizationException("User has no authority to " + String.join(" / ", permissions));
|
||||
}
|
||||
|
||||
private static JsonObject json(final String body) {
|
||||
if (StringUtils.isBlank(body)) {
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement e = JsonParser.parseString(body);
|
||||
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
|
||||
}
|
||||
|
||||
private static String text(final JsonObject in, final String key) {
|
||||
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
private static int clamp(final int limit) {
|
||||
return Math.max(1, Math.min(200, limit));
|
||||
}
|
||||
}
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.businessdate.service.BusinessDateReadPlatformService;
|
||||
import org.apache.fineract.infrastructure.core.config.FineractProperties;
|
||||
import org.apache.fineract.infrastructure.core.domain.ActionContext;
|
||||
import org.apache.fineract.infrastructure.core.domain.FineractPlatformTenant;
|
||||
import org.apache.fineract.infrastructure.core.service.ThreadLocalContextUtil;
|
||||
import org.apache.fineract.infrastructure.core.service.tenant.TenantDetailsService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Fetches enquiry alerts automatically: every few minutes, for every tenant, each whitelisted bureau with automatic
|
||||
* fetch on is fetched once its interval has passed since the last fetch. Runs only on the batch-manager node.
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CicAlertScheduler {
|
||||
|
||||
private final FineractProperties fineractProperties;
|
||||
private final TenantDetailsService tenantDetailsService;
|
||||
private final BusinessDateReadPlatformService businessDateReadPlatformService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CicAlertService alertService;
|
||||
|
||||
@Scheduled(fixedDelayString = "${cbs.cic.alert-scheduler-delay-ms:300000}", initialDelayString = "${cbs.cic.alert-scheduler-initial-delay-ms:180000}")
|
||||
public void fetchDueAlerts() {
|
||||
if (!fineractProperties.getMode().isBatchManagerEnabled()) {
|
||||
return;
|
||||
}
|
||||
for (final FineractPlatformTenant tenant : tenantDetailsService.findAllTenants()) {
|
||||
try {
|
||||
ThreadLocalContextUtil.setTenant(tenant);
|
||||
ThreadLocalContextUtil.setActionContext(ActionContext.DEFAULT);
|
||||
ThreadLocalContextUtil.setBusinessDates(businessDateReadPlatformService.getBusinessDates());
|
||||
final List<Map<String, Object>> due = jdbcTemplate.queryForList("""
|
||||
SELECT id, code, alert_fetch_interval_min, last_fetch_on FROM m_cic_bureau
|
||||
WHERE whitelisted = TRUE AND alert_auto_fetch = TRUE
|
||||
""");
|
||||
final long now = Instant.now().toEpochMilli();
|
||||
for (final Map<String, Object> b : due) {
|
||||
final long intervalMs = ((Number) b.get("alert_fetch_interval_min")).longValue() * 60_000L;
|
||||
final Object last = b.get("last_fetch_on");
|
||||
if (last instanceof Timestamp ts && now - ts.getTime() < intervalMs) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
alertService.fetch(((Number) b.get("id")).longValue(), null, null, "SCHEDULED", null);
|
||||
} catch (final RuntimeException e) {
|
||||
// A bureau that is not ready (no URL, no credential ...) is reported on the run / bureau, not retried here.
|
||||
log.warn("CIC scheduled alert fetch of {} for tenant {} failed: {}", b.get("code"), tenant.getTenantIdentifier(),
|
||||
e.getMessage());
|
||||
}
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
// A tenant whose database does not have the CIC tables yet.
|
||||
log.debug("CIC alert scheduler skipped tenant {}: {}", tenant.getTenantIdentifier(), e.getMessage());
|
||||
} finally {
|
||||
ThreadLocalContextUtil.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1041
File diff suppressed because it is too large
Load Diff
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonPrimitive;
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Everything the API Mapping screen can choose from for the ENQUIRY_ALERT call, and the transforms applied when a
|
||||
* value is written into the bureau request or read back from the bureau response.
|
||||
*/
|
||||
public final class CicCatalog {
|
||||
|
||||
private CicCatalog() {}
|
||||
|
||||
public static final List<String> ENVIRONMENTS = List.of("SIMULATOR", "UAT", "PRODUCTION");
|
||||
public static final List<String> AUTH_TYPES = List.of("NONE", "BASIC", "API_KEY", "BEARER_TOKEN", "OAUTH2_CLIENT_CREDENTIALS");
|
||||
public static final String ALERT_ENDPOINT = "ENQUIRY_ALERT";
|
||||
public static final List<String> ENDPOINT_TYPES = List.of(ALERT_ENDPOINT);
|
||||
public static final List<String> HTTP_METHODS = List.of("POST", "PUT", "GET");
|
||||
public static final List<String> ALERT_STATUSES = List.of("NEW", "SEEN", "ACTIONED", "DISMISSED");
|
||||
public static final List<String> MATCH_TYPES = List.of("PAN", "AADHAAR", "CUSTOMER_REF", "MANUAL", "NONE");
|
||||
|
||||
/** Values a REQUEST mapping can take: the fetch window and the bank's identity at the bureau. */
|
||||
public static final Map<String, String> CBS_FIELDS = new LinkedHashMap<>();
|
||||
static {
|
||||
CBS_FIELDS.put("fetch.reference", "Fetch reference number");
|
||||
CBS_FIELDS.put("fetch.fromDate", "Alerts from date");
|
||||
CBS_FIELDS.put("fetch.toDate", "Alerts to date");
|
||||
CBS_FIELDS.put("fetch.date", "Request date (business date)");
|
||||
CBS_FIELDS.put("bureau.memberCode", "Bank's member code at the bureau");
|
||||
CBS_FIELDS.put("bureau.username", "Bureau user id");
|
||||
}
|
||||
|
||||
/**
|
||||
* Result fields a RESPONSE mapping can fill. errorCode / errorMessage / alerts are read from the root of the
|
||||
* response; "alerts" points at the list, and every other field is read from each item of that list.
|
||||
*/
|
||||
public static final Map<String, String> RESULT_FIELDS = new LinkedHashMap<>();
|
||||
static {
|
||||
RESULT_FIELDS.put("alerts", "List of alerts (array) - root");
|
||||
RESULT_FIELDS.put("errorCode", "Bureau error code - root");
|
||||
RESULT_FIELDS.put("errorMessage", "Bureau error message - root");
|
||||
RESULT_FIELDS.put("alertRef", "Alert / enquiry id at the bureau");
|
||||
RESULT_FIELDS.put("pan", "Customer PAN");
|
||||
RESULT_FIELDS.put("aadhaar", "Customer Aadhaar (masked or last 4)");
|
||||
RESULT_FIELDS.put("customerName", "Customer name");
|
||||
RESULT_FIELDS.put("dateOfBirth", "Customer date of birth");
|
||||
RESULT_FIELDS.put("customerRef", "Our customer reference (CIF / account no) reported to the bureau");
|
||||
RESULT_FIELDS.put("institutionCode", "Enquiring lender's member code");
|
||||
RESULT_FIELDS.put("institutionName", "Enquiring lender (other bank / NBFC)");
|
||||
RESULT_FIELDS.put("institutionType", "Lender type (Bank / NBFC / MFI ...)");
|
||||
RESULT_FIELDS.put("enquiryDate", "Enquiry date");
|
||||
RESULT_FIELDS.put("loanType", "Loan type applied for");
|
||||
RESULT_FIELDS.put("amount", "Loan amount applied for");
|
||||
RESULT_FIELDS.put("purpose", "Enquiry purpose");
|
||||
RESULT_FIELDS.put("location", "Branch / city of the enquiry");
|
||||
}
|
||||
|
||||
/** Result fields read from the root of the response, not from each alert. */
|
||||
public static final Set<String> ROOT_FIELDS = Set.of("alerts", "errorCode", "errorMessage");
|
||||
|
||||
public static final Map<String, String> TRANSFORMS = new LinkedHashMap<>();
|
||||
static {
|
||||
TRANSFORMS.put("NONE", "As is");
|
||||
TRANSFORMS.put("UPPER", "UPPER CASE");
|
||||
TRANSFORMS.put("LOWER", "lower case");
|
||||
TRANSFORMS.put("TRIM", "Trim spaces");
|
||||
TRANSFORMS.put("DIGITS_ONLY", "Digits only");
|
||||
TRANSFORMS.put("DATE_DDMMYYYY", "Date ddMMyyyy");
|
||||
TRANSFORMS.put("DATE_DD_MM_YYYY", "Date dd-MM-yyyy");
|
||||
TRANSFORMS.put("DATE_DD_SLASH_MM_YYYY", "Date dd/MM/yyyy");
|
||||
TRANSFORMS.put("DATE_YYYY_MM_DD", "Date yyyy-MM-dd");
|
||||
TRANSFORMS.put("AMOUNT_INTEGER", "Amount, whole rupees");
|
||||
TRANSFORMS.put("NUMBER", "Number");
|
||||
TRANSFORMS.put("BOOLEAN", "true / false");
|
||||
}
|
||||
|
||||
private static final Map<String, String> DATE_PATTERNS = Map.of("DATE_DDMMYYYY", "ddMMyyyy", "DATE_DD_MM_YYYY", "dd-MM-yyyy",
|
||||
"DATE_DD_SLASH_MM_YYYY", "dd/MM/yyyy", "DATE_YYYY_MM_DD", "yyyy-MM-dd");
|
||||
|
||||
private static final DateTimeFormatter ISO = DateTimeFormatter.ISO_LOCAL_DATE;
|
||||
|
||||
/** Applies a REQUEST transform to a CBS value; null stays null. Dates come in as ISO yyyy-MM-dd. */
|
||||
public static JsonElement applyRequestTransform(final String transform, final Object raw) {
|
||||
if (raw == null) {
|
||||
return null;
|
||||
}
|
||||
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||
final String s = String.valueOf(raw).trim();
|
||||
if (s.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
if (DATE_PATTERNS.containsKey(t)) {
|
||||
try {
|
||||
return new JsonPrimitive(LocalDate.parse(s.length() > 10 ? s.substring(0, 10) : s, ISO)
|
||||
.format(DateTimeFormatter.ofPattern(DATE_PATTERNS.get(t))));
|
||||
} catch (final RuntimeException e) {
|
||||
return new JsonPrimitive(s);
|
||||
}
|
||||
}
|
||||
switch (t) {
|
||||
case "UPPER":
|
||||
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||
case "LOWER":
|
||||
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||
case "TRIM":
|
||||
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||
case "DIGITS_ONLY": {
|
||||
final String digits = s.replaceAll("\\D", "");
|
||||
return digits.isEmpty() ? null : new JsonPrimitive(digits);
|
||||
}
|
||||
case "AMOUNT_INTEGER":
|
||||
return new JsonPrimitive(new BigDecimal(s).setScale(0, RoundingMode.HALF_UP).toBigInteger());
|
||||
case "NUMBER":
|
||||
return new JsonPrimitive(new BigDecimal(s).stripTrailingZeros());
|
||||
case "BOOLEAN":
|
||||
return new JsonPrimitive(Boolean.parseBoolean(s) || "1".equals(s) || "Y".equalsIgnoreCase(s));
|
||||
default:
|
||||
return new JsonPrimitive(s);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a value read from a bureau response. A DATE_* transform names the format the bureau sends, and the
|
||||
* value comes out as ISO yyyy-MM-dd. Arrays and objects pass through.
|
||||
*/
|
||||
public static JsonElement applyResponseTransform(final String transform, final JsonElement value) {
|
||||
if (value == null || value.isJsonNull() || !value.isJsonPrimitive()) {
|
||||
return value;
|
||||
}
|
||||
final String t = transform == null || transform.isBlank() ? "NONE" : transform;
|
||||
final String s = value.getAsString().trim();
|
||||
try {
|
||||
if (DATE_PATTERNS.containsKey(t)) {
|
||||
return new JsonPrimitive(parseDate(s, DATE_PATTERNS.get(t)).toString());
|
||||
}
|
||||
switch (t) {
|
||||
case "NUMBER":
|
||||
case "AMOUNT_INTEGER": {
|
||||
final String numeric = s.replaceAll("[^0-9.\\-]", "");
|
||||
return numeric.isEmpty() ? null : new JsonPrimitive(new BigDecimal(numeric));
|
||||
}
|
||||
case "BOOLEAN":
|
||||
return new JsonPrimitive("true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "YES".equalsIgnoreCase(s));
|
||||
case "UPPER":
|
||||
return new JsonPrimitive(s.toUpperCase(Locale.ROOT));
|
||||
case "LOWER":
|
||||
return new JsonPrimitive(s.toLowerCase(Locale.ROOT));
|
||||
case "TRIM":
|
||||
return new JsonPrimitive(s.replaceAll("\\s+", " "));
|
||||
case "DIGITS_ONLY":
|
||||
return new JsonPrimitive(s.replaceAll("\\D", ""));
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses a bureau date in the given pattern, falling back to ISO. */
|
||||
public static LocalDate parseDate(final String s, final String pattern) {
|
||||
final String v = s.trim();
|
||||
try {
|
||||
return LocalDate.parse(v, DateTimeFormatter.ofPattern(pattern));
|
||||
} catch (final RuntimeException e) {
|
||||
return LocalDate.parse(v.length() > 10 ? v.substring(0, 10) : v, ISO);
|
||||
}
|
||||
}
|
||||
|
||||
/** ABCDE1234F -> AB****234F style masking for screens and logs. */
|
||||
public static String mask(final String value) {
|
||||
if (value == null || value.length() <= 4) {
|
||||
return value == null ? null : "****";
|
||||
}
|
||||
final int keepStart = Math.min(2, value.length() / 4);
|
||||
final int keepEnd = Math.min(4, value.length() / 3);
|
||||
return value.substring(0, keepStart) + "*".repeat(value.length() - keepStart - keepEnd) + value.substring(value.length() - keepEnd);
|
||||
}
|
||||
|
||||
/** Aadhaar shown as XXXX-XXXX-1234 whatever form the bureau sends it in. */
|
||||
public static String maskAadhaar(final String value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
final String digits = value.replaceAll("\\D", "");
|
||||
return digits.length() < 4 ? null : "XXXX-XXXX-" + digits.substring(digits.length() - 4);
|
||||
}
|
||||
}
|
||||
+463
@@ -0,0 +1,463 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Pattern;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* The bank's bureau master (whitelist, environment, connection, encrypted credentials) and each bureau's API mapping:
|
||||
* endpoint, headers, base body and the REQUEST / RESPONSE field mappings.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CicConfigService {
|
||||
|
||||
private static final Pattern RESULT_KEY = Pattern.compile("^[A-Za-z][A-Za-z0-9_]{0,99}$");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final CicHttpGateway gateway;
|
||||
|
||||
// ── Bureaus ───────────────────────────────────────────────────────────────
|
||||
|
||||
/** Full row including the encrypted secret - internal use only, never returned by the API. */
|
||||
public Map<String, Object> bureauRow(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cic_bureau WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.cic.bureau.not.found", "Credit bureau " + id + " was not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listBureaus(final boolean whitelistedOnly) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> row : jdbcTemplate.queryForList(
|
||||
"SELECT * FROM m_cic_bureau" + (whitelistedOnly ? " WHERE whitelisted = TRUE" : "") + " ORDER BY is_default DESC, id")) {
|
||||
out.add(publicView(row));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
public Map<String, Object> getBureau(final Long id) {
|
||||
return publicView(bureauRow(id));
|
||||
}
|
||||
|
||||
/** A bureau row as the screens see it: never the secret itself, only whether one is set. */
|
||||
private Map<String, Object> publicView(final Map<String, Object> row) {
|
||||
final Map<String, Object> v = new LinkedHashMap<>();
|
||||
final Long id = ((Number) row.get("id")).longValue();
|
||||
v.put("id", id);
|
||||
v.put("code", row.get("code"));
|
||||
v.put("name", row.get("name"));
|
||||
v.put("description", row.get("description"));
|
||||
v.put("whitelisted", row.get("whitelisted"));
|
||||
v.put("isDefault", row.get("is_default"));
|
||||
v.put("environment", row.get("environment"));
|
||||
v.put("baseUrl", row.get("base_url"));
|
||||
v.put("memberCode", row.get("member_code"));
|
||||
v.put("authType", row.get("auth_type"));
|
||||
v.put("username", row.get("username"));
|
||||
v.put("apiKeyHeader", row.get("api_key_header"));
|
||||
v.put("hasSecret", row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank());
|
||||
v.put("tokenUrl", row.get("token_url"));
|
||||
v.put("clientId", row.get("client_id"));
|
||||
v.put("timeoutMs", row.get("timeout_ms"));
|
||||
v.put("alertAutoFetch", row.get("alert_auto_fetch"));
|
||||
v.put("alertFetchIntervalMin", row.get("alert_fetch_interval_min"));
|
||||
v.put("alertLookbackDays", row.get("alert_lookback_days"));
|
||||
v.put("lastFetchOn", row.get("last_fetch_on") == null ? null : row.get("last_fetch_on").toString());
|
||||
v.put("lastFetchStatus", row.get("last_fetch_status"));
|
||||
v.put("lastFetchMessage", row.get("last_fetch_message"));
|
||||
v.put("lastTestStatus", row.get("last_test_status"));
|
||||
v.put("lastTestMessage", row.get("last_test_message"));
|
||||
v.put("lastTestedOn", row.get("last_tested_on") == null ? null : row.get("last_tested_on").toString());
|
||||
v.put("updatedOn", row.get("updated_on") == null ? null : row.get("updated_on").toString());
|
||||
v.put("activeEndpoints", jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, id));
|
||||
v.put("alertsLast30Days", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND received_on >= ?",
|
||||
Integer.class, id, Timestamp.from(Instant.now().minusSeconds(30L * 86400))));
|
||||
v.put("newAlerts", jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_cic_alert WHERE bureau_id = ? AND status = 'NEW'",
|
||||
Integer.class, id));
|
||||
return v;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> updateBureau(final Long id, final JsonObject in, final AppUser user) {
|
||||
final Map<String, Object> row = bureauRow(id);
|
||||
final String environment = upper(text(in, "environment", (String) row.get("environment")));
|
||||
if (!CicCatalog.ENVIRONMENTS.contains(environment)) {
|
||||
throw fail("error.msg.cic.environment.invalid", "Environment must be one of " + CicCatalog.ENVIRONMENTS + ".");
|
||||
}
|
||||
final String authType = upper(text(in, "authType", (String) row.get("auth_type")));
|
||||
if (!CicCatalog.AUTH_TYPES.contains(authType)) {
|
||||
throw fail("error.msg.cic.auth.invalid", "Authentication must be one of " + CicCatalog.AUTH_TYPES + ".");
|
||||
}
|
||||
final String baseUrl = trimToNull(text(in, "baseUrl", (String) row.get("base_url")));
|
||||
if (baseUrl != null && !StringUtils.startsWithAny(baseUrl, "https://", "http://")) {
|
||||
throw fail("error.msg.cic.url.invalid", "Base URL must start with https:// (or http:// for a test server).");
|
||||
}
|
||||
if ("PRODUCTION".equals(environment) && baseUrl != null && !baseUrl.startsWith("https://")) {
|
||||
throw fail("error.msg.cic.url.https", "A PRODUCTION bureau must be reached over https://.");
|
||||
}
|
||||
final String tokenUrl = trimToNull(text(in, "tokenUrl", (String) row.get("token_url")));
|
||||
final int timeoutMs = intOf(in, "timeoutMs", ((Number) row.get("timeout_ms")).intValue());
|
||||
if (timeoutMs < 1000 || timeoutMs > 300000) {
|
||||
throw fail("error.msg.cic.timeout.invalid", "Timeout must be between 1,000 and 300,000 milliseconds.");
|
||||
}
|
||||
final boolean autoFetch = in.has("alertAutoFetch") && !in.get("alertAutoFetch").isJsonNull() ? in.get("alertAutoFetch").getAsBoolean()
|
||||
: Boolean.TRUE.equals(row.get("alert_auto_fetch"));
|
||||
final int interval = intOf(in, "alertFetchIntervalMin", ((Number) row.get("alert_fetch_interval_min")).intValue());
|
||||
if (interval < 15 || interval > 1440) {
|
||||
throw fail("error.msg.cic.interval.invalid", "Automatic fetch interval must be between 15 minutes and 24 hours (1440).");
|
||||
}
|
||||
final int lookback = intOf(in, "alertLookbackDays", ((Number) row.get("alert_lookback_days")).intValue());
|
||||
if (lookback < 1 || lookback > 90) {
|
||||
throw fail("error.msg.cic.lookback.invalid", "Look-back window must be between 1 and 90 days.");
|
||||
}
|
||||
|
||||
String secretEnc = (String) row.get("secret_enc");
|
||||
if (in.has("clearSecret") && in.get("clearSecret").getAsBoolean()) {
|
||||
secretEnc = null;
|
||||
} else if (in.has("secret") && !in.get("secret").isJsonNull() && !in.get("secret").getAsString().isBlank()) {
|
||||
secretEnc = gateway.encrypt(in.get("secret").getAsString());
|
||||
}
|
||||
|
||||
final boolean makeDefault = in.has("isDefault") && !in.get("isDefault").isJsonNull() && in.get("isDefault").getAsBoolean();
|
||||
if (makeDefault) {
|
||||
jdbcTemplate.update("UPDATE m_cic_bureau SET is_default = FALSE WHERE id <> ?", id);
|
||||
}
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_cic_bureau SET name = ?, description = ?, environment = ?, base_url = ?, member_code = ?, auth_type = ?,
|
||||
username = ?, api_key_header = ?, secret_enc = ?, token_url = ?, client_id = ?, timeout_ms = ?,
|
||||
alert_auto_fetch = ?, alert_fetch_interval_min = ?, alert_lookback_days = ?, is_default = ?, updated_by = ?,
|
||||
updated_on = ?
|
||||
WHERE id = ?
|
||||
""", StringUtils.defaultIfBlank(text(in, "name", (String) row.get("name")), (String) row.get("name")),
|
||||
trimToNull(text(in, "description", (String) row.get("description"))), environment, baseUrl,
|
||||
trimToNull(text(in, "memberCode", (String) row.get("member_code"))), authType,
|
||||
trimToNull(text(in, "username", (String) row.get("username"))),
|
||||
StringUtils.defaultIfBlank(text(in, "apiKeyHeader", (String) row.get("api_key_header")), "X-API-KEY"), secretEnc, tokenUrl,
|
||||
trimToNull(text(in, "clientId", (String) row.get("client_id"))), timeoutMs, autoFetch, interval, lookback,
|
||||
makeDefault || Boolean.TRUE.equals(row.get("is_default")), user.getId(), Timestamp.from(Instant.now()), id);
|
||||
gateway.evictToken(id);
|
||||
|
||||
// A whitelisted bureau must stay usable after the edit; otherwise every alert fetch would fail silently.
|
||||
if (Boolean.TRUE.equals(row.get("whitelisted"))) {
|
||||
assertReadyForLive(bureauRow(id));
|
||||
}
|
||||
return getBureau(id);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> setWhitelisted(final Long id, final boolean whitelisted, final AppUser user) {
|
||||
final Map<String, Object> row = bureauRow(id);
|
||||
if (whitelisted) {
|
||||
assertReadyForLive(row);
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_cic_bureau SET whitelisted = ?, updated_by = ?, updated_on = ? WHERE id = ?", whitelisted,
|
||||
user.getId(), Timestamp.from(Instant.now()), id);
|
||||
return getBureau(id);
|
||||
}
|
||||
|
||||
/** What a bureau needs before alerts may be fetched from it. */
|
||||
public void assertReadyForLive(final Map<String, Object> row) {
|
||||
final String code = (String) row.get("code");
|
||||
final Integer endpoints = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND is_active = TRUE", Integer.class, row.get("id"));
|
||||
if (endpoints == null || endpoints == 0) {
|
||||
throw fail("error.msg.cic.no.endpoint", code + " has no active API endpoint. Configure it under API Mapping first.");
|
||||
}
|
||||
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||
return;
|
||||
}
|
||||
if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||
throw fail("error.msg.cic.no.url", code + " has no base URL for " + row.get("environment") + ".");
|
||||
}
|
||||
final String auth = (String) row.get("auth_type");
|
||||
final boolean hasSecret = row.get("secret_enc") != null && !row.get("secret_enc").toString().isBlank();
|
||||
if (!"NONE".equals(auth) && !hasSecret) {
|
||||
throw fail("error.msg.cic.no.secret", code + " uses " + auth + " but no password / key / secret is stored.");
|
||||
}
|
||||
if ("BASIC".equals(auth) && StringUtils.isBlank((String) row.get("username"))) {
|
||||
throw fail("error.msg.cic.no.username", code + " uses BASIC authentication but has no username.");
|
||||
}
|
||||
if ("OAUTH2_CLIENT_CREDENTIALS".equals(auth)
|
||||
&& (StringUtils.isBlank((String) row.get("token_url")) || StringUtils.isBlank((String) row.get("client_id")))) {
|
||||
throw fail("error.msg.cic.no.oauth", code + " uses OAuth2 but the token URL or client id is missing.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Reachability and credentials check; the result is kept on the bureau for the configuration screen. */
|
||||
public Map<String, Object> testConnection(final Long id) {
|
||||
final Map<String, Object> row = bureauRow(id);
|
||||
String status;
|
||||
String message;
|
||||
if ("SIMULATOR".equals(row.get("environment"))) {
|
||||
status = "OK";
|
||||
message = "Simulator mode - no network call is made; alerts are answered by the built-in simulator.";
|
||||
} else if (StringUtils.isBlank((String) row.get("base_url"))) {
|
||||
status = "FAILED";
|
||||
message = "No base URL configured.";
|
||||
} else {
|
||||
try {
|
||||
if ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type"))) {
|
||||
gateway.evictToken(id);
|
||||
gateway.oauthToken(row);
|
||||
}
|
||||
final CicHttpGateway.HttpResult r = gateway.send(row, "GET", (String) row.get("base_url"), Map.of(), null, null);
|
||||
if (r.status() == 0) {
|
||||
status = "FAILED";
|
||||
message = "Not reachable: " + r.error();
|
||||
} else if (r.status() == 401 || r.status() == 403) {
|
||||
status = "FAILED";
|
||||
message = "Reachable, but the bureau refused the credentials (HTTP " + r.status() + ").";
|
||||
} else {
|
||||
status = "OK";
|
||||
message = "Reachable (HTTP " + r.status() + " in " + r.durationMs() + " ms)"
|
||||
+ ("OAUTH2_CLIENT_CREDENTIALS".equals(row.get("auth_type")) ? ", OAuth2 token obtained." : ".");
|
||||
}
|
||||
} catch (final IOException | RuntimeException e) {
|
||||
status = "FAILED";
|
||||
message = e.getMessage();
|
||||
}
|
||||
}
|
||||
jdbcTemplate.update("UPDATE m_cic_bureau SET last_test_status = ?, last_test_message = ?, last_tested_on = ? WHERE id = ?", status,
|
||||
StringUtils.abbreviate(message, 1000), Timestamp.from(Instant.now()), id);
|
||||
final Map<String, Object> res = new LinkedHashMap<>();
|
||||
res.put("status", status);
|
||||
res.put("message", message);
|
||||
return res;
|
||||
}
|
||||
|
||||
// ── Endpoints and mappings ────────────────────────────────────────────────
|
||||
|
||||
public Map<String, Object> endpointRow(final Long bureauId, final String type) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT * FROM m_cic_bureau_endpoint WHERE bureau_id = ? AND endpoint_type = ?", bureauId, type);
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> mappingRows(final Long endpointId, final String direction) {
|
||||
return jdbcTemplate.queryForList(
|
||||
"SELECT * FROM m_cic_field_mapping WHERE endpoint_id = ? AND direction = ? ORDER BY sort_order, id", endpointId, direction);
|
||||
}
|
||||
|
||||
public Map<String, Object> getEndpoint(final Long bureauId, final String type) {
|
||||
bureauRow(bureauId);
|
||||
final String t = upper(type);
|
||||
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||
}
|
||||
final Map<String, Object> e = endpointRow(bureauId, t);
|
||||
final Map<String, Object> v = new LinkedHashMap<>();
|
||||
v.put("bureauId", bureauId);
|
||||
v.put("endpointType", t);
|
||||
v.put("exists", e != null);
|
||||
v.put("id", e == null ? null : e.get("id"));
|
||||
v.put("httpMethod", e == null ? "POST" : e.get("http_method"));
|
||||
v.put("path", e == null ? null : e.get("path"));
|
||||
v.put("contentType", e == null ? "application/json" : e.get("content_type"));
|
||||
v.put("headersJson", e == null ? "{}" : e.get("headers_json"));
|
||||
v.put("baseBody", e == null ? "{}" : e.get("base_body"));
|
||||
v.put("isActive", e == null ? Boolean.FALSE : e.get("is_active"));
|
||||
v.put("requestMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "REQUEST")));
|
||||
v.put("responseMappings", e == null ? List.of() : mappingView(mappingRows(((Number) e.get("id")).longValue(), "RESPONSE")));
|
||||
return v;
|
||||
}
|
||||
|
||||
private static List<Map<String, Object>> mappingView(final List<Map<String, Object>> rows) {
|
||||
final List<Map<String, Object>> out = new ArrayList<>();
|
||||
for (final Map<String, Object> m : rows) {
|
||||
final Map<String, Object> v = new LinkedHashMap<>();
|
||||
v.put("id", m.get("id"));
|
||||
v.put("sourceField", m.get("source_field"));
|
||||
v.put("targetField", m.get("target_field"));
|
||||
v.put("transform", m.get("transform"));
|
||||
v.put("defaultValue", m.get("default_value"));
|
||||
v.put("mandatory", m.get("is_mandatory"));
|
||||
v.put("sortOrder", m.get("sort_order"));
|
||||
out.add(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Saves an endpoint and replaces its whole mapping in one transaction, after validating every row. */
|
||||
@Transactional
|
||||
public Map<String, Object> saveEndpoint(final Long bureauId, final String type, final JsonObject in, final AppUser user) {
|
||||
final Map<String, Object> bureau = bureauRow(bureauId);
|
||||
final String t = upper(type);
|
||||
if (!CicCatalog.ENDPOINT_TYPES.contains(t)) {
|
||||
throw fail("error.msg.cic.endpoint.type.invalid", "Endpoint type must be one of " + CicCatalog.ENDPOINT_TYPES + ".");
|
||||
}
|
||||
final String method = upper(StringUtils.defaultIfBlank(text(in, "httpMethod", "POST"), "POST"));
|
||||
if (!CicCatalog.HTTP_METHODS.contains(method)) {
|
||||
throw fail("error.msg.cic.method.invalid", "HTTP method must be one of " + CicCatalog.HTTP_METHODS + ".");
|
||||
}
|
||||
final String headersJson = StringUtils.defaultIfBlank(text(in, "headersJson", "{}"), "{}");
|
||||
final String baseBody = StringUtils.defaultIfBlank(text(in, "baseBody", "{}"), "{}");
|
||||
requireJsonObject(headersJson, "Headers");
|
||||
requireJsonObject(baseBody, "Base request body");
|
||||
for (final Map.Entry<String, JsonElement> h : JsonParser.parseString(headersJson).getAsJsonObject().entrySet()) {
|
||||
if (!h.getValue().isJsonPrimitive()) {
|
||||
throw fail("error.msg.cic.header.invalid", "Header '" + h.getKey() + "' must be a plain text value.");
|
||||
}
|
||||
}
|
||||
final boolean active = !in.has("isActive") || in.get("isActive").isJsonNull() || in.get("isActive").getAsBoolean();
|
||||
|
||||
final List<Object[]> rows = new ArrayList<>();
|
||||
validateMappings(in.has("requestMappings") ? in.getAsJsonArray("requestMappings") : new JsonArray(), "REQUEST", rows);
|
||||
validateMappings(in.has("responseMappings") ? in.getAsJsonArray("responseMappings") : new JsonArray(), "RESPONSE", rows);
|
||||
if (active && rows.stream().noneMatch(r -> "REQUEST".equals(r[0]))) {
|
||||
throw fail("error.msg.cic.no.request.mapping", "An active endpoint needs at least one REQUEST mapping.");
|
||||
}
|
||||
if (active && rows.stream().noneMatch(r -> "RESPONSE".equals(r[0]) && "alerts".equals(r[2]))) {
|
||||
throw fail("error.msg.cic.no.alert.list",
|
||||
"Map the response path of the alert list to the result field 'alerts' (e.g. data.alerts), or the alerts cannot be read.");
|
||||
}
|
||||
|
||||
Map<String, Object> existing = endpointRow(bureauId, t);
|
||||
final Timestamp now = Timestamp.from(Instant.now());
|
||||
if (existing == null) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body,
|
||||
is_active, updated_by, updated_on)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", bureauId, t, method, trimToNull(text(in, "path", null)),
|
||||
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||
user.getId(), now);
|
||||
existing = endpointRow(bureauId, t);
|
||||
} else {
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_cic_bureau_endpoint SET http_method = ?, path = ?, content_type = ?, headers_json = ?, base_body = ?, is_active = ?,
|
||||
updated_by = ?, updated_on = ?
|
||||
WHERE id = ?
|
||||
""", method, trimToNull(text(in, "path", null)),
|
||||
StringUtils.defaultIfBlank(text(in, "contentType", "application/json"), "application/json"), headersJson, baseBody, active,
|
||||
user.getId(), now, existing.get("id"));
|
||||
}
|
||||
final Long endpointId = ((Number) existing.get("id")).longValue();
|
||||
jdbcTemplate.update("DELETE FROM m_cic_field_mapping WHERE endpoint_id = ?", endpointId);
|
||||
for (final Object[] r : rows) {
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory,
|
||||
sort_order)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", endpointId, r[0], r[1], r[2], r[3], r[4], r[5], r[6]);
|
||||
}
|
||||
// Switching off the last active endpoint of a whitelisted bureau would leave it whitelisted but unusable.
|
||||
if (Boolean.TRUE.equals(bureau.get("whitelisted"))) {
|
||||
assertReadyForLive(bureauRow(bureauId));
|
||||
}
|
||||
return getEndpoint(bureauId, t);
|
||||
}
|
||||
|
||||
private void validateMappings(final JsonArray array, final String direction, final List<Object[]> out) {
|
||||
int order = 0;
|
||||
for (final JsonElement el : array) {
|
||||
final JsonObject m = el.getAsJsonObject();
|
||||
order += 10;
|
||||
final String source = trimToNull(text(m, "sourceField", null));
|
||||
final String target = trimToNull(text(m, "targetField", null));
|
||||
final String transform = StringUtils.defaultIfBlank(upper(text(m, "transform", "NONE")), "NONE");
|
||||
final String defaultValue = trimToNull(text(m, "defaultValue", null));
|
||||
final boolean mandatory = m.has("mandatory") && !m.get("mandatory").isJsonNull() && m.get("mandatory").getAsBoolean();
|
||||
final int sortOrder = m.has("sortOrder") && !m.get("sortOrder").isJsonNull() ? m.get("sortOrder").getAsInt() : order;
|
||||
final String label = direction + " row " + (order / 10);
|
||||
if (!CicCatalog.TRANSFORMS.containsKey(transform)) {
|
||||
throw fail("error.msg.cic.transform.invalid", label + ": unknown transform '" + transform + "'.");
|
||||
}
|
||||
if (target == null) {
|
||||
throw fail("error.msg.cic.mapping.target.required", label + ": the target field is required.");
|
||||
}
|
||||
if ("REQUEST".equals(direction)) {
|
||||
if (source == null && defaultValue == null) {
|
||||
throw fail("error.msg.cic.mapping.source.required", label + " (" + target + "): choose a CBS field or give a fixed value.");
|
||||
}
|
||||
if (source != null && !CicCatalog.CBS_FIELDS.containsKey(source)) {
|
||||
throw fail("error.msg.cic.mapping.source.invalid", label + ": unknown CBS field '" + source + "'.");
|
||||
}
|
||||
if (!CicJsonPath.isValid(target)) {
|
||||
throw fail("error.msg.cic.mapping.path.invalid", label + ": '" + target + "' is not a valid JSON path (e.g. consumer.ids[0].number).");
|
||||
}
|
||||
} else {
|
||||
if (source == null || !CicJsonPath.isValid(source)) {
|
||||
throw fail("error.msg.cic.mapping.path.invalid",
|
||||
label + ": the bureau response path is missing or invalid (e.g. report.score.value).");
|
||||
}
|
||||
if (!RESULT_KEY.matcher(target).matches()) {
|
||||
throw fail("error.msg.cic.mapping.result.invalid", label + ": result field '" + target + "' must be a simple name.");
|
||||
}
|
||||
}
|
||||
out.add(new Object[] { direction, source, target, transform, defaultValue, mandatory, sortOrder });
|
||||
}
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
private static void requireJsonObject(final String text, final String label) {
|
||||
try {
|
||||
if (!JsonParser.parseString(text).isJsonObject()) {
|
||||
throw new IllegalArgumentException();
|
||||
}
|
||||
} catch (final RuntimeException e) {
|
||||
throw fail("error.msg.cic.json.invalid", label + " must be a valid JSON object.");
|
||||
}
|
||||
}
|
||||
|
||||
static String text(final JsonObject in, final String key, final String fallback) {
|
||||
if (in == null || !in.has(key)) {
|
||||
return fallback;
|
||||
}
|
||||
return in.get(key).isJsonNull() ? null : in.get(key).getAsString();
|
||||
}
|
||||
|
||||
private static int intOf(final JsonObject in, final String key, final int fallback) {
|
||||
return in.has(key) && !in.get(key).isJsonNull() ? in.get(key).getAsInt() : fallback;
|
||||
}
|
||||
|
||||
private static String upper(final String s) {
|
||||
return s == null ? null : s.trim().toUpperCase();
|
||||
}
|
||||
|
||||
private static String trimToNull(final String s) {
|
||||
return StringUtils.trimToNull(s);
|
||||
}
|
||||
|
||||
static GeneralPlatformDomainRuleException fail(final String code, final String message) {
|
||||
return new GeneralPlatformDomainRuleException(code, message);
|
||||
}
|
||||
}
|
||||
+194
@@ -0,0 +1,194 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import okhttp3.FormBody;
|
||||
import okhttp3.MediaType;
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.RequestBody;
|
||||
import okhttp3.Response;
|
||||
import okhttp3.ResponseBody;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.database.DatabasePasswordEncryptor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Outbound HTTP to a credit bureau: URL joining, authentication (Basic, API key, bearer, OAuth2 client credentials
|
||||
* with an in-memory token cache), per-bureau timeouts. The bureau's secret is stored encrypted and is only decrypted
|
||||
* here, for the call itself.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class CicHttpGateway {
|
||||
|
||||
private static final int MAX_BODY_CHARS = 2_000_000;
|
||||
|
||||
private final DatabasePasswordEncryptor encryptor;
|
||||
private final Map<Long, CachedToken> tokens = new ConcurrentHashMap<>();
|
||||
|
||||
private record CachedToken(String token, Instant expiresAt) {}
|
||||
|
||||
/** What came back from the bureau. A transport failure is a status of 0 with the error in {@code body}. */
|
||||
public record HttpResult(int status, String body, long durationMs, String error) {
|
||||
|
||||
public boolean ok() {
|
||||
return status >= 200 && status < 300;
|
||||
}
|
||||
}
|
||||
|
||||
public String encrypt(final String plain) {
|
||||
return plain == null ? null : encryptor.encrypt(plain);
|
||||
}
|
||||
|
||||
private String secretOf(final Map<String, Object> bureau) {
|
||||
final Object enc = bureau.get("secret_enc");
|
||||
if (enc == null || enc.toString().isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return encryptor.decrypt(enc.toString());
|
||||
} catch (final RuntimeException e) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.cic.secret.unreadable",
|
||||
"The stored credential of " + bureau.get("code") + " cannot be decrypted (was the master password changed?)."
|
||||
+ " Re-enter it on the bureau configuration.");
|
||||
}
|
||||
}
|
||||
|
||||
public static String joinUrl(final String baseUrl, final String path) {
|
||||
if (StringUtils.startsWithAny(path, "http://", "https://")) {
|
||||
return path;
|
||||
}
|
||||
final String base = StringUtils.defaultString(baseUrl).replaceAll("/+$", "");
|
||||
if (StringUtils.isBlank(path)) {
|
||||
return base;
|
||||
}
|
||||
return base + (path.startsWith("/") ? path : "/" + path);
|
||||
}
|
||||
|
||||
private OkHttpClient client(final Map<String, Object> bureau) {
|
||||
final int timeout = bureau.get("timeout_ms") instanceof Number n && n.intValue() > 0 ? n.intValue() : 30000;
|
||||
return new OkHttpClient.Builder().connectTimeout(timeout, TimeUnit.MILLISECONDS).readTimeout(timeout, TimeUnit.MILLISECONDS)
|
||||
.writeTimeout(timeout, TimeUnit.MILLISECONDS).build();
|
||||
}
|
||||
|
||||
/** Sends one request to the bureau with its configured authentication. */
|
||||
public HttpResult send(final Map<String, Object> bureau, final String method, final String url, final Map<String, String> headers,
|
||||
final String contentType, final String body) {
|
||||
final long started = System.currentTimeMillis();
|
||||
try {
|
||||
final Request.Builder rb = new Request.Builder().url(url);
|
||||
headers.forEach((k, v) -> {
|
||||
if (StringUtils.isNotBlank(k) && v != null) {
|
||||
rb.header(k, v);
|
||||
}
|
||||
});
|
||||
applyAuth(bureau, rb);
|
||||
final String m = StringUtils.defaultIfBlank(method, "POST").toUpperCase();
|
||||
if ("GET".equals(m)) {
|
||||
rb.get();
|
||||
} else {
|
||||
final MediaType mediaType = MediaType.parse(StringUtils.defaultIfBlank(contentType, "application/json"));
|
||||
rb.method(m, RequestBody.create(StringUtils.defaultString(body), mediaType));
|
||||
}
|
||||
try (Response response = client(bureau).newCall(rb.build()).execute()) {
|
||||
return new HttpResult(response.code(), read(response.body()), System.currentTimeMillis() - started, null);
|
||||
}
|
||||
} catch (final IOException | IllegalArgumentException e) {
|
||||
log.warn("CIC {}: call to {} failed: {}", bureau.get("code"), url, e.getMessage());
|
||||
return new HttpResult(0, null, System.currentTimeMillis() - started, e.getClass().getSimpleName() + ": " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private static String read(final ResponseBody body) throws IOException {
|
||||
if (body == null) {
|
||||
return null;
|
||||
}
|
||||
final String text = new String(body.bytes(), StandardCharsets.UTF_8);
|
||||
return text.length() > MAX_BODY_CHARS ? text.substring(0, MAX_BODY_CHARS) : text;
|
||||
}
|
||||
|
||||
private void applyAuth(final Map<String, Object> bureau, final Request.Builder rb) throws IOException {
|
||||
final String auth = StringUtils.defaultIfBlank((String) bureau.get("auth_type"), "NONE");
|
||||
final String username = (String) bureau.get("username");
|
||||
switch (auth) {
|
||||
case "BASIC" -> {
|
||||
final String raw = StringUtils.defaultString(username) + ":" + StringUtils.defaultString(secretOf(bureau));
|
||||
rb.header("Authorization", "Basic " + Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8)));
|
||||
}
|
||||
case "API_KEY" -> rb.header(StringUtils.defaultIfBlank((String) bureau.get("api_key_header"), "X-API-KEY"),
|
||||
StringUtils.defaultString(secretOf(bureau)));
|
||||
case "BEARER_TOKEN" -> rb.header("Authorization", "Bearer " + StringUtils.defaultString(secretOf(bureau)));
|
||||
case "OAUTH2_CLIENT_CREDENTIALS" -> rb.header("Authorization", "Bearer " + oauthToken(bureau));
|
||||
default -> {
|
||||
// NONE: the bureau authenticates by IP whitelisting / mutual TLS at the network layer.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** OAuth2 client-credentials token, cached until a minute before it expires. */
|
||||
public String oauthToken(final Map<String, Object> bureau) throws IOException {
|
||||
final Long id = ((Number) bureau.get("id")).longValue();
|
||||
final CachedToken cached = tokens.get(id);
|
||||
if (cached != null && Instant.now().isBefore(cached.expiresAt())) {
|
||||
return cached.token();
|
||||
}
|
||||
final String tokenUrl = (String) bureau.get("token_url");
|
||||
if (StringUtils.isBlank(tokenUrl)) {
|
||||
throw new IOException("OAuth2 token URL is not configured");
|
||||
}
|
||||
final FormBody form = new FormBody.Builder().add("grant_type", "client_credentials")
|
||||
.add("client_id", StringUtils.defaultString((String) bureau.get("client_id")))
|
||||
.add("client_secret", StringUtils.defaultString(secretOf(bureau))).build();
|
||||
try (Response response = client(bureau).newCall(new Request.Builder().url(tokenUrl).post(form).build()).execute()) {
|
||||
final String text = read(response.body());
|
||||
if (!response.isSuccessful()) {
|
||||
throw new IOException("Token endpoint returned HTTP " + response.code() + ": " + StringUtils.abbreviate(text, 300));
|
||||
}
|
||||
final JsonElement json = JsonParser.parseString(StringUtils.defaultIfBlank(text, "{}"));
|
||||
final JsonObject obj = json.isJsonObject() ? json.getAsJsonObject() : new JsonObject();
|
||||
if (!obj.has("access_token")) {
|
||||
throw new IOException("Token endpoint response has no access_token");
|
||||
}
|
||||
final long expiresIn = obj.has("expires_in") ? obj.get("expires_in").getAsLong() : 300L;
|
||||
final String token = obj.get("access_token").getAsString();
|
||||
tokens.put(id, new CachedToken(token, Instant.now().plusSeconds(Math.max(30, expiresIn - 60))));
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
/** Forget a cached token (after the bureau's credentials change). */
|
||||
public void evictToken(final Long bureauId) {
|
||||
tokens.remove(bureauId);
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonNull;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
* Minimal dotted JSON path used by the bureau API mapping: {@code consumer.name.firstName},
|
||||
* {@code consumer.ids[0].number}, {@code report.score.value}. Setting creates the missing objects and arrays on the
|
||||
* way; reading returns null for anything absent.
|
||||
*/
|
||||
public final class CicJsonPath {
|
||||
|
||||
private static final Pattern SEGMENT = Pattern.compile("^([^\\[\\]]*)((?:\\[\\d+\\])*)$");
|
||||
private static final Pattern INDEX = Pattern.compile("\\[(\\d+)\\]");
|
||||
|
||||
private CicJsonPath() {}
|
||||
|
||||
/** One step of a path: an object key, optionally followed by array indexes. */
|
||||
private record Step(String key, List<Integer> indexes) {}
|
||||
|
||||
private static List<Step> parse(final String path) {
|
||||
if (path == null || path.isBlank()) {
|
||||
throw new IllegalArgumentException("Empty JSON path");
|
||||
}
|
||||
final List<Step> steps = new ArrayList<>();
|
||||
for (final String raw : path.trim().split("\\.")) {
|
||||
final Matcher m = SEGMENT.matcher(raw);
|
||||
if (!m.matches()) {
|
||||
throw new IllegalArgumentException("Invalid JSON path segment '" + raw + "' in '" + path + "'");
|
||||
}
|
||||
final List<Integer> indexes = new ArrayList<>();
|
||||
final Matcher im = INDEX.matcher(m.group(2));
|
||||
while (im.find()) {
|
||||
indexes.add(Integer.parseInt(im.group(1)));
|
||||
}
|
||||
steps.add(new Step(m.group(1), indexes));
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
/** Whether the path is syntactically valid. */
|
||||
public static boolean isValid(final String path) {
|
||||
try {
|
||||
parse(path);
|
||||
return true;
|
||||
} catch (final IllegalArgumentException e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonElement get(final JsonElement root, final String path) {
|
||||
JsonElement current = root;
|
||||
for (final Step step : parse(path)) {
|
||||
if (!step.key().isEmpty()) {
|
||||
if (current == null || !current.isJsonObject()) {
|
||||
return null;
|
||||
}
|
||||
current = current.getAsJsonObject().get(step.key());
|
||||
}
|
||||
for (final Integer index : step.indexes()) {
|
||||
if (current == null || !current.isJsonArray() || current.getAsJsonArray().size() <= index) {
|
||||
return null;
|
||||
}
|
||||
current = current.getAsJsonArray().get(index);
|
||||
}
|
||||
}
|
||||
return current == null || current.isJsonNull() ? null : current;
|
||||
}
|
||||
|
||||
public static void set(final JsonObject root, final String path, final JsonElement value) {
|
||||
final List<Step> steps = parse(path);
|
||||
JsonElement current = root;
|
||||
for (int s = 0; s < steps.size(); s++) {
|
||||
final Step step = steps.get(s);
|
||||
final boolean lastStep = s == steps.size() - 1;
|
||||
if (!step.key().isEmpty()) {
|
||||
final JsonObject obj = current.getAsJsonObject();
|
||||
if (lastStep && step.indexes().isEmpty()) {
|
||||
obj.add(step.key(), value == null ? JsonNull.INSTANCE : value);
|
||||
return;
|
||||
}
|
||||
JsonElement next = obj.get(step.key());
|
||||
if (next == null || next.isJsonNull()) {
|
||||
next = step.indexes().isEmpty() ? new JsonObject() : new JsonArray();
|
||||
obj.add(step.key(), next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
for (int i = 0; i < step.indexes().size(); i++) {
|
||||
final int index = step.indexes().get(i);
|
||||
final boolean lastIndex = lastStep && i == step.indexes().size() - 1;
|
||||
final JsonArray array = current.getAsJsonArray();
|
||||
while (array.size() <= index) {
|
||||
array.add(JsonNull.INSTANCE);
|
||||
}
|
||||
if (lastIndex) {
|
||||
array.set(index, value == null ? JsonNull.INSTANCE : value);
|
||||
return;
|
||||
}
|
||||
JsonElement next = array.get(index);
|
||||
if (next == null || next.isJsonNull()) {
|
||||
next = i < step.indexes().size() - 1 ? new JsonArray() : new JsonObject();
|
||||
array.set(index, next);
|
||||
}
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 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.cic.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Random;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Stand-in CIC for the SIMULATOR environment. Plays the bureau's enquiry-alert service: for every day of the asked
|
||||
* window it reports a few of this bank's own customers as enquired by other lenders, in the shape of the seeded generic
|
||||
* mapping. Deterministic per bureau and day, so fetching the same window again yields the same alerts (and they are
|
||||
* de-duplicated). Now and then it also sends an alert for an unknown customer and one raised by this bank itself, so
|
||||
* the unmatched and own-bank paths can be seen too. Never used once the bureau is set to UAT or PRODUCTION.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CicSimulator {
|
||||
|
||||
private static final String[][] LENDERS = { { "HDFC BANK LTD", "BANK" }, { "STATE BANK OF INDIA", "BANK" }, { "ICICI BANK LTD", "BANK" },
|
||||
{ "BAJAJ FINANCE LTD", "NBFC" }, { "MUTHOOT FINANCE LTD", "NBFC" }, { "UJJIVAN SMALL FINANCE BANK", "SMALL FINANCE BANK" },
|
||||
{ "BANDHAN BANK LTD", "BANK" }, { "SATIN CREDITCARE NETWORK", "MFI" }, { "MADHYA PRADESH GRAMIN BANK", "REGIONAL RURAL BANK" },
|
||||
{ "SARASWAT CO-OPERATIVE BANK", "CO-OPERATIVE BANK" }, { "TATA CAPITAL LTD", "NBFC" }, { "PUNJAB NATIONAL BANK", "BANK" } };
|
||||
private static final Object[][] LOAN_TYPES = { { "PERSONAL LOAN", 50_000, 1_000_000 }, { "HOUSING LOAN", 800_000, 6_000_000 },
|
||||
{ "AUTO LOAN", 300_000, 1_500_000 }, { "TWO-WHEELER LOAN", 50_000, 200_000 }, { "GOLD LOAN", 20_000, 500_000 },
|
||||
{ "BUSINESS LOAN", 200_000, 5_000_000 }, { "CREDIT CARD", 25_000, 300_000 }, { "KISAN CREDIT CARD", 50_000, 300_000 },
|
||||
{ "EDUCATION LOAN", 200_000, 2_000_000 }, { "CONSUMER DURABLE LOAN", 10_000, 150_000 },
|
||||
{ "LOAN AGAINST PROPERTY", 500_000, 5_000_000 }, { "MICROFINANCE LOAN", 20_000, 100_000 } };
|
||||
private static final String[] CITIES = { "BHOPAL", "INDORE", "VIDISHA", "SAGAR", "JABALPUR", "GWALIOR", "BASODA", "RAISEN" };
|
||||
private static final DateTimeFormatter DDMMYYYY = DateTimeFormatter.ofPattern("ddMMyyyy");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
public JsonObject respond(final Map<String, Object> bureau, final LocalDate from, final LocalDate to, final String reference) {
|
||||
final String code = String.valueOf(bureau.get("code"));
|
||||
final String ownMemberCode = bureau.get("member_code") == null ? "OWNBANK" : bureau.get("member_code").toString();
|
||||
final List<Map<String, Object>> clients = jdbcTemplate.queryForList("""
|
||||
SELECT c.id, c.display_name, c.account_no, c.date_of_birth,
|
||||
(SELECT k.pan_number FROM m_client_kyc k WHERE k.client_id = c.id AND k.pan_number IS NOT NULL
|
||||
ORDER BY k.id DESC LIMIT 1) AS pan,
|
||||
(SELECT k.aadhaar_number_masked FROM m_client_kyc k WHERE k.client_id = c.id
|
||||
AND k.aadhaar_number_masked IS NOT NULL ORDER BY k.id DESC LIMIT 1) AS aadhaar
|
||||
FROM m_client c
|
||||
WHERE c.status_enum = 300
|
||||
ORDER BY c.id
|
||||
LIMIT 300
|
||||
""");
|
||||
|
||||
final JsonArray alerts = new JsonArray();
|
||||
final long days = Math.min(90, ChronoUnit.DAYS.between(from, to));
|
||||
for (int d = 0; d <= days; d++) {
|
||||
final LocalDate day = from.plusDays(d);
|
||||
final Random r = new Random(Math.abs((long) (code + "|" + day).hashCode()));
|
||||
final int count = clients.isEmpty() ? 0 : r.nextInt(4);
|
||||
int seq = 0;
|
||||
for (int i = 0; i < count; i++) {
|
||||
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, null));
|
||||
}
|
||||
if (r.nextInt(8) == 0) {
|
||||
// A consumer the bureau has on file for this bank but whom the bank no longer has (or never had) under that id.
|
||||
alerts.add(alert(code, day, ++seq, Map.of("display_name", "UNKNOWN CUSTOMER", "pan", "ZZZPZ" + (1000 + r.nextInt(8999)) + "Z"),
|
||||
r, null));
|
||||
}
|
||||
if (!clients.isEmpty() && r.nextInt(10) == 0) {
|
||||
// An enquiry this bank made itself - the bureau reports it, the CBS must skip it.
|
||||
alerts.add(alert(code, day, ++seq, clients.get(r.nextInt(clients.size())), r, ownMemberCode));
|
||||
}
|
||||
}
|
||||
|
||||
final JsonObject header = new JsonObject();
|
||||
header.addProperty("status", "SUCCESS");
|
||||
header.addProperty("referenceNumber", reference);
|
||||
header.addProperty("bureau", code);
|
||||
header.addProperty("fromDate", from.format(DDMMYYYY));
|
||||
header.addProperty("toDate", to.format(DDMMYYYY));
|
||||
final JsonObject data = new JsonObject();
|
||||
data.addProperty("totalAlerts", alerts.size());
|
||||
data.add("alerts", alerts);
|
||||
final JsonObject root = new JsonObject();
|
||||
root.add("responseHeader", header);
|
||||
root.add("data", data);
|
||||
return root;
|
||||
}
|
||||
|
||||
private static JsonObject alert(final String code, final LocalDate day, final int seq, final Map<String, Object> client, final Random r,
|
||||
final String forcedMemberCode) {
|
||||
final JsonObject consumer = new JsonObject();
|
||||
consumer.addProperty("name", String.valueOf(client.get("display_name")).toUpperCase());
|
||||
final Object pan = client.get("pan");
|
||||
final Object aadhaar = client.get("aadhaar");
|
||||
if (pan != null && !pan.toString().isBlank()) {
|
||||
consumer.addProperty("pan", pan.toString().trim().toUpperCase());
|
||||
} else if (aadhaar != null && !aadhaar.toString().isBlank()) {
|
||||
consumer.addProperty("aadhaar", "XXXXXXXX" + lastFour(aadhaar.toString()));
|
||||
} else if (client.get("account_no") != null) {
|
||||
consumer.addProperty("memberReference", client.get("account_no").toString());
|
||||
}
|
||||
if (client.get("date_of_birth") != null) {
|
||||
consumer.addProperty("dateOfBirth", LocalDate.parse(client.get("date_of_birth").toString().substring(0, 10)).format(DDMMYYYY));
|
||||
}
|
||||
|
||||
final int lenderIdx = r.nextInt(LENDERS.length);
|
||||
final Object[] type = LOAN_TYPES[r.nextInt(LOAN_TYPES.length)];
|
||||
final int min = (Integer) type[1];
|
||||
final int max = (Integer) type[2];
|
||||
final long amount = Math.round((min + r.nextDouble() * (max - min)) / 5000.0) * 5000L;
|
||||
|
||||
final JsonObject enquiry = new JsonObject();
|
||||
enquiry.addProperty("memberCode", forcedMemberCode != null ? forcedMemberCode : "SIM" + String.format("%04d", 101 + lenderIdx));
|
||||
enquiry.addProperty("memberName", forcedMemberCode != null ? "THIS BANK" : LENDERS[lenderIdx][0]);
|
||||
enquiry.addProperty("memberType", forcedMemberCode != null ? "BANK" : LENDERS[lenderIdx][1]);
|
||||
enquiry.addProperty("date", day.format(DDMMYYYY));
|
||||
enquiry.addProperty("productType", (String) type[0]);
|
||||
enquiry.addProperty("amount", amount);
|
||||
enquiry.addProperty("purpose", r.nextInt(5) == 0 ? "LOAN RENEWAL / ENHANCEMENT" : "NEW LOAN APPLICATION");
|
||||
enquiry.addProperty("location", CITIES[r.nextInt(CITIES.length)]);
|
||||
|
||||
final JsonObject a = new JsonObject();
|
||||
a.addProperty("alertId", code + "-" + day.format(DDMMYYYY) + "-" + seq);
|
||||
a.add("consumer", consumer);
|
||||
a.add("enquiry", enquiry);
|
||||
return a;
|
||||
}
|
||||
|
||||
private static String lastFour(final String value) {
|
||||
final String digits = value.replaceAll("\\D", "");
|
||||
return digits.length() >= 4 ? digits.substring(digits.length() - 4) : "0000";
|
||||
}
|
||||
}
|
||||
+6
-235
@@ -83,13 +83,12 @@ public class KycContactAuditApiResource {
|
||||
@Operation(summary = "Notices per day", description = "Newest day first: customers reached, notices sent and their outcomes.")
|
||||
public String days(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real) {
|
||||
@QueryParam("search") final String search) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final String where = filters(from, to, channel, status, search, real, args);
|
||||
final String where = filters(from, to, channel, status, search, args);
|
||||
final String sql = "SELECT CAST(l.contacted_at AS DATE) AS d, COUNT(*) AS notices, COUNT(DISTINCT l.client_id) AS customers, "
|
||||
+ "COUNT(DISTINCT CASE WHEN l.delivery_status = 'SENT' THEN l.client_id END) AS notified, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'SKIPPED_NO_DESTINATION' THEN 1 ELSE 0 END) AS no_contact, "
|
||||
@@ -103,8 +102,6 @@ public class KycContactAuditApiResource {
|
||||
item.put("date", String.valueOf(r.get("d")));
|
||||
item.put("notices", number(r.get("notices")));
|
||||
item.put("customers", number(r.get("customers")));
|
||||
// Customers a notice actually reached that day, as against customers it was tried for.
|
||||
item.put("notifiedCustomers", number(r.get("notified")));
|
||||
item.put("delivered", number(r.get("delivered")));
|
||||
item.put("failed", number(r.get("failed")));
|
||||
item.put("noContact", number(r.get("no_contact")));
|
||||
@@ -121,12 +118,12 @@ public class KycContactAuditApiResource {
|
||||
@Operation(summary = "Retrieve customer notices on record", description = "Newest first, a page at a time. Returns total and rows.")
|
||||
public String retrieve(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
|
||||
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
|
||||
@QueryParam("search") final String search, @QueryParam("offset") final Integer offset,
|
||||
@QueryParam("limit") final Integer limit) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final String where = filters(from, to, channel, status, search, real, args);
|
||||
final String where = filters(from, to, channel, status, search, args);
|
||||
|
||||
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(*)" + FROM_CLAUSE + where, Long.class, args.toArray());
|
||||
|
||||
@@ -163,193 +160,10 @@ public class KycContactAuditApiResource {
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* One line per customer: how many notices, how they fared, and the latest. Newest activity first, a page at a time.
|
||||
* Answers "who was contacted, and what happened" without paging through every day.
|
||||
*/
|
||||
@GET
|
||||
@Path("customers")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Notices per customer", description = "Same filters as the day view. Returns total and rows.")
|
||||
public String customers(@QueryParam("from") final String from, @QueryParam("to") final String to,
|
||||
@QueryParam("channel") final String channel, @QueryParam("status") final String status,
|
||||
@QueryParam("search") final String search, @QueryParam("real") final Boolean real,
|
||||
@QueryParam("offset") final Integer offset, @QueryParam("limit") final Integer limit) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Object> args = new ArrayList<>();
|
||||
final String where = filters(from, to, channel, status, search, real, args);
|
||||
|
||||
final Long total = this.jdbcTemplate.queryForObject("SELECT COUNT(DISTINCT l.client_id)" + FROM_CLAUSE + where, Long.class,
|
||||
args.toArray());
|
||||
|
||||
final int rows = Math.min(Math.max(limit == null ? 50 : limit, 1), MAX_PAGE);
|
||||
final int skip = Math.max(offset == null ? 0 : offset, 0);
|
||||
final String sql = "SELECT l.client_id, c.display_name, c.account_no, COUNT(*) AS notices, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'SENT' THEN 1 ELSE 0 END) AS delivered, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'FAILED' THEN 1 ELSE 0 END) AS failed, "
|
||||
+ "SUM(CASE WHEN l.delivery_status IN ('SKIPPED_NO_DESTINATION', 'PROVIDER_NOT_CONFIGURED') THEN 1 ELSE 0 END) AS not_sent, "
|
||||
+ "SUM(CASE WHEN l.delivery_status = 'NOT_APPLICABLE' THEN 1 ELSE 0 END) AS recorded, "
|
||||
+ "MAX(l.contacted_at) AS last_at" + FROM_CLAUSE + where
|
||||
+ " GROUP BY l.client_id, c.display_name, c.account_no ORDER BY last_at DESC, l.client_id DESC LIMIT " + rows + " OFFSET "
|
||||
+ skip;
|
||||
|
||||
final List<Map<String, Object>> list = new ArrayList<>();
|
||||
final List<Long> pageIds = new ArrayList<>();
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(sql, args.toArray())) {
|
||||
final Map<String, Object> item = new LinkedHashMap<>();
|
||||
item.put("clientId", r.get("client_id"));
|
||||
item.put("clientName", r.get("display_name"));
|
||||
item.put("accountNo", r.get("account_no"));
|
||||
item.put("notices", number(r.get("notices")));
|
||||
item.put("delivered", number(r.get("delivered")));
|
||||
item.put("failed", number(r.get("failed")));
|
||||
item.put("notSent", number(r.get("not_sent")));
|
||||
item.put("recorded", number(r.get("recorded")));
|
||||
final Object at = r.get("last_at");
|
||||
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||
pageIds.add(((Number) r.get("client_id")).longValue());
|
||||
list.add(item);
|
||||
}
|
||||
|
||||
// The latest notice's channel and outcome, for the customers on this page only.
|
||||
if (!pageIds.isEmpty()) {
|
||||
final String marks = String.join(",", pageIds.stream().map(x -> "?").toList());
|
||||
final Map<Long, Map<String, Object>> byId = new LinkedHashMap<>();
|
||||
list.forEach((m) -> byId.put(((Number) m.get("clientId")).longValue(), m));
|
||||
final List<Object> latestArgs = new ArrayList<>(pageIds);
|
||||
for (final Map<String, Object> r : this.jdbcTemplate.queryForList(
|
||||
"SELECT client_id, channel, delivery_status FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
||||
+ ") ORDER BY contacted_at DESC, id DESC",
|
||||
latestArgs.toArray())) {
|
||||
final Map<String, Object> item = byId.get(((Number) r.get("client_id")).longValue());
|
||||
if (item != null && !item.containsKey("lastChannel")) {
|
||||
item.put("lastChannel", r.get("channel"));
|
||||
item.put("lastStatus", r.get("delivery_status"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("total", total == null ? 0 : total);
|
||||
out.put("rows", list);
|
||||
return this.gson.toJson(out);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the listed customers have been sent so far: the latest notice and which channels have been done.
|
||||
* One round trip for a whole screen of customers, so a list can show "last notice" without a call per row.
|
||||
*/
|
||||
@GET
|
||||
@Path("clients")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Notice summary for some customers", description = "ids is a comma-separated list of client ids (up to 500).")
|
||||
public String clients(@QueryParam("ids") final String ids) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
|
||||
final List<Long> clientIds = new ArrayList<>();
|
||||
if (StringUtils.isNotBlank(ids)) {
|
||||
for (final String part : ids.split(",")) {
|
||||
if (StringUtils.isNumeric(part.trim()) && clientIds.size() < MAX_PAGE) {
|
||||
clientIds.add(Long.valueOf(part.trim()));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (clientIds.isEmpty()) {
|
||||
return this.gson.toJson(new ArrayList<>());
|
||||
}
|
||||
|
||||
final String marks = String.join(",", clientIds.stream().map(x -> "?").toList());
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(
|
||||
"SELECT client_id, channel, delivery_status, contacted_at, message_body FROM m_client_kyc_contact_log WHERE client_id IN (" + marks
|
||||
+ ") ORDER BY contacted_at DESC, id DESC",
|
||||
clientIds.toArray());
|
||||
|
||||
final Map<Long, Map<String, Object>> byClient = new LinkedHashMap<>();
|
||||
// Every customer asked for comes back, with or without notices, so the screen knows who has no mobile or email.
|
||||
for (final Map<String, Object> c : this.jdbcTemplate.queryForList(
|
||||
"SELECT c.id, c.mobile_no, c.email_address, "
|
||||
+ "COALESCE((SELECT SUM(s.account_balance_derived) FROM m_savings_account s WHERE s.client_id = c.id AND s.status_enum = 300), 0) "
|
||||
+ "+ COALESCE((SELECT SUM(a.account_balance_derived) FROM m_current_account a WHERE a.client_id = c.id AND a.status_enum = 300), 0) AS balance "
|
||||
+ "FROM m_client c WHERE c.id IN (" + marks + ")", clientIds.toArray())) {
|
||||
final Long id = ((Number) c.get("id")).longValue();
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("clientId", id);
|
||||
m.put("count", 0);
|
||||
m.put("lastAt", null);
|
||||
m.put("lastChannel", null);
|
||||
m.put("lastStatus", null);
|
||||
m.put("doneChannels", new ArrayList<String>());
|
||||
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
||||
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
||||
// What the customer holds in open savings and current accounts.
|
||||
m.put("balance", c.get("balance"));
|
||||
m.put("hasMobile", StringUtils.isNotBlank((String) c.get("mobile_no")));
|
||||
m.put("hasEmail", StringUtils.isNotBlank((String) c.get("email_address")));
|
||||
// Masked, never the full value: the screen only needs to show that something is on file.
|
||||
m.put("mobileMasked", maskMobile((String) c.get("mobile_no")));
|
||||
m.put("emailMasked", maskEmail((String) c.get("email_address")));
|
||||
byClient.put(id, m);
|
||||
}
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final Long clientId = ((Number) r.get("client_id")).longValue();
|
||||
final Map<String, Object> item = byClient.computeIfAbsent(clientId, k -> {
|
||||
final Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("clientId", k);
|
||||
m.put("count", 0);
|
||||
m.put("lastAt", null);
|
||||
m.put("lastChannel", null);
|
||||
m.put("lastStatus", null);
|
||||
m.put("doneChannels", new ArrayList<String>());
|
||||
m.put("doneByStage", new LinkedHashMap<String, List<String>>());
|
||||
m.put("noticesByStage", new LinkedHashMap<String, Integer>());
|
||||
return m;
|
||||
});
|
||||
if (item.get("lastAt") == null) {
|
||||
// Rows come newest first, so the first one seen is the latest notice.
|
||||
final Object at = r.get("contacted_at");
|
||||
item.put("lastAt", at == null ? null : String.valueOf(at).replace(' ', 'T'));
|
||||
item.put("lastChannel", r.get("channel"));
|
||||
item.put("lastStatus", r.get("delivery_status"));
|
||||
}
|
||||
item.put("count", ((Integer) item.get("count")) + 1);
|
||||
// A channel counts as done once it reached the customer, or was recorded (a call or a visit).
|
||||
final String status = String.valueOf(r.get("delivery_status"));
|
||||
final String channel = String.valueOf(r.get("channel"));
|
||||
@SuppressWarnings("unchecked")
|
||||
final List<String> done = (List<String>) item.get("doneChannels");
|
||||
if (("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) && !done.contains(channel)) {
|
||||
done.add(channel);
|
||||
}
|
||||
// The same, by the stage the notice was written for, so a notice sent at an earlier stage is not taken for this one.
|
||||
// A call, a visit or any other record that is not a stage notice is filed under ANY: it counts at whatever stage the customer is at.
|
||||
final String found = stageOfNotice((String) r.get("message_body"));
|
||||
final String stage = found == null ? "ANY" : found;
|
||||
if ("SENT".equals(status) || "NOT_APPLICABLE".equals(status)) {
|
||||
@SuppressWarnings("unchecked")
|
||||
final Map<String, List<String>> byStage = (Map<String, List<String>>) item.get("doneByStage");
|
||||
@SuppressWarnings("unchecked")
|
||||
final Map<String, Integer> counts = (Map<String, Integer>) item.get("noticesByStage");
|
||||
counts.merge(stage, 1, Integer::sum);
|
||||
final List<String> inStage = byStage.computeIfAbsent(stage, k -> new ArrayList<>());
|
||||
if (!inStage.contains(channel)) {
|
||||
inStage.add(channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
return this.gson.toJson(new ArrayList<>(byClient.values()));
|
||||
}
|
||||
|
||||
/** The WHERE tail shared by both views; fills {@code args} in step with its placeholders. */
|
||||
private String filters(final String from, final String to, final String channel, final String status, final String search,
|
||||
final Boolean real, final List<Object> args) {
|
||||
final List<Object> args) {
|
||||
final StringBuilder where = new StringBuilder();
|
||||
if (Boolean.TRUE.equals(real)) {
|
||||
// An attempt that went nowhere (no number or address on file, or no gateway set up) is not a notice a customer received.
|
||||
where.append(" AND l.delivery_status IN ('SENT', 'FAILED', 'NOT_APPLICABLE')");
|
||||
}
|
||||
if (StringUtils.isNotBlank(from)) {
|
||||
where.append(" AND l.contacted_at >= ?");
|
||||
args.add(Timestamp.valueOf(parse(from, "from").atStartOfDay()));
|
||||
@@ -376,49 +190,6 @@ public class KycContactAuditApiResource {
|
||||
return where.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage a notice was written for, read from its wording (the composer words every stage differently). Null for a call,
|
||||
* a visit or any message that is not one of the stage notices.
|
||||
*/
|
||||
private static String stageOfNotice(final String body) {
|
||||
if (StringUtils.isBlank(body)) {
|
||||
return null;
|
||||
}
|
||||
if (body.startsWith("Your KYC expires on")) {
|
||||
return "EXPIRING";
|
||||
}
|
||||
if (body.startsWith("Your KYC expired on")) {
|
||||
if (body.contains("account is now inoperative")) {
|
||||
return "INOPERATIVE";
|
||||
}
|
||||
return body.contains("debits on your account are now stopped") ? "PARTIAL_FREEZE" : "GRACE";
|
||||
}
|
||||
if (body.startsWith("Your account ")) {
|
||||
if (body.contains("has been closed")) {
|
||||
return "ESCHEAT";
|
||||
}
|
||||
return body.contains("is dormant") ? "DORMANT" : body.contains("is inactive") ? "INACTIVE" : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String maskMobile(final String mobile) {
|
||||
if (StringUtils.isBlank(mobile)) {
|
||||
return null;
|
||||
}
|
||||
final String digits = mobile.trim();
|
||||
return digits.length() <= 4 ? digits : "XXXXXX" + digits.substring(digits.length() - 4);
|
||||
}
|
||||
|
||||
private static String maskEmail(final String email) {
|
||||
if (StringUtils.isBlank(email) || !email.contains("@")) {
|
||||
return StringUtils.isBlank(email) ? null : email;
|
||||
}
|
||||
final String trimmed = email.trim();
|
||||
final int at = trimmed.indexOf('@');
|
||||
return trimmed.substring(0, Math.min(1, at)) + "***" + trimmed.substring(at);
|
||||
}
|
||||
|
||||
private static long number(final Object value) {
|
||||
return value == null ? 0 : ((Number) value).longValue();
|
||||
}
|
||||
|
||||
-1
@@ -108,7 +108,6 @@ public class KycNoticeQueueApiResource {
|
||||
final JsonObject out = new JsonObject();
|
||||
out.addProperty("queued", result.queued());
|
||||
out.addProperty("skipped", result.skipped());
|
||||
out.addProperty("unreachable", result.unreachable());
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
|
||||
+11
@@ -24,6 +24,7 @@ import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
@@ -69,4 +70,14 @@ public class KycRiskRulesApiResource {
|
||||
final KycRiskRuleData rule = this.writePlatformService.createRule(request);
|
||||
return this.toApiJsonSerializer.serialize(rule);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{ruleId}/mark-normal")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
@Operation(summary = "Mark a rule as normal", description = "Ends the trial of a newly added rule. It stays in the catalog as a normal rule.")
|
||||
public String markNormal(@PathParam("ruleId") final Long ruleId) {
|
||||
this.context.authenticatedUser().validateHasUpdatePermission(ClientApiConstants.CLIENT_RESOURCE_NAME);
|
||||
return this.toApiJsonSerializer.serialize(this.writePlatformService.markNormal(ruleId));
|
||||
}
|
||||
}
|
||||
|
||||
+16
-1
@@ -66,6 +66,10 @@ public final class KycRiskRuleData implements Serializable {
|
||||
private final BigDecimal txnMaxLimitAmount;
|
||||
/** True when the scenario applies to Cash Credit (overdraft-bearing current) accounts. */
|
||||
private final boolean ccRelevant;
|
||||
/** True while a newly authored rule is on trial; it is listed first until an officer marks it normal. */
|
||||
private final boolean onTrial;
|
||||
/** When the trial began (ISO date), or null for a rule that is not on trial. */
|
||||
private final String trialStartedOn;
|
||||
|
||||
public KycRiskRuleData(final Long id, final String ruleName, final String ruleKind, final Integer legalFormId,
|
||||
final Long occupationCategoryCvId, final String occupationCategoryName,
|
||||
@@ -78,7 +82,8 @@ public final class KycRiskRuleData implements Serializable {
|
||||
final String willfulDefaulter, final String ecgcSalFlag, final String dormantFlag,
|
||||
final BigDecimal txnThresholdAmount, final String txnRuleType, final Integer txnWindowDays,
|
||||
final BigDecimal txnThresholdAmountMediumRisk, final BigDecimal txnThresholdAmountHighRisk,
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant) {
|
||||
final BigDecimal txnMinLimitAmount, final BigDecimal txnMaxLimitAmount, final boolean ccRelevant,
|
||||
final boolean onTrial, final String trialStartedOn) {
|
||||
this.id = id;
|
||||
this.ruleName = ruleName;
|
||||
this.ruleKind = ruleKind;
|
||||
@@ -109,6 +114,8 @@ public final class KycRiskRuleData implements Serializable {
|
||||
this.txnMinLimitAmount = txnMinLimitAmount;
|
||||
this.txnMaxLimitAmount = txnMaxLimitAmount;
|
||||
this.ccRelevant = ccRelevant;
|
||||
this.onTrial = onTrial;
|
||||
this.trialStartedOn = trialStartedOn;
|
||||
}
|
||||
|
||||
public Long getId() {
|
||||
@@ -230,4 +237,12 @@ public final class KycRiskRuleData implements Serializable {
|
||||
public boolean isCcRelevant() {
|
||||
return ccRelevant;
|
||||
}
|
||||
|
||||
public boolean isOnTrial() {
|
||||
return onTrial;
|
||||
}
|
||||
|
||||
public String getTrialStartedOn() {
|
||||
return trialStartedOn;
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -104,15 +104,26 @@ public class KycRiskRuleRequest {
|
||||
@Schema(description = "THRESHOLD | STRUCTURING | VELOCITY. Defaults to THRESHOLD.", example = "THRESHOLD")
|
||||
private String txnRuleType;
|
||||
|
||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | ALL. Defaults to ALL.", example = "ALL")
|
||||
@Schema(description = "SAVINGS | LOAN | TRANSFER | CURRENT | ALL. Defaults to ALL. CURRENT is a current / Cash Credit account.", example = "ALL")
|
||||
private String txnAppliesTo;
|
||||
|
||||
@Schema(description = "CREDIT (money in) or DEBIT (money out). Omit for either direction.", example = "CREDIT")
|
||||
private String txnDirection;
|
||||
|
||||
@Schema(description = "True when the rule is about cash only. Defaults to false.")
|
||||
private Boolean txnCashOnly;
|
||||
|
||||
@Schema(description = "True when the scenario watches Cash Credit accounts, so the Rules Master CC chip lists it.")
|
||||
private Boolean ccRelevant;
|
||||
|
||||
/**
|
||||
* True when the officer has described a live-transaction rule rather than a pure KYC profile
|
||||
* rule, and a companion {@code m_aml_transaction_rule} row is therefore warranted.
|
||||
*/
|
||||
public boolean hasTransactionRule() {
|
||||
return this.txnMinLimitAmount != null || this.txnMaxLimitAmount != null || this.txnThresholdAmount != null
|
||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0);
|
||||
|| (this.txnWindowDays != null && this.txnWindowDays > 0)
|
||||
|| (this.txnDirection != null && !this.txnDirection.isBlank()) || Boolean.TRUE.equals(this.txnCashOnly)
|
||||
|| (this.txnAppliesTo != null && !this.txnAppliesTo.isBlank() && !"ALL".equalsIgnoreCase(this.txnAppliesTo.trim()));
|
||||
}
|
||||
}
|
||||
|
||||
+8
-16
@@ -122,36 +122,28 @@ public class KycNoticeComposer {
|
||||
return "Your account is inoperative";
|
||||
}
|
||||
|
||||
/** What the customer is told, by the stage they are at: where they stand and what happens next, and in how many days. */
|
||||
/** What the customer is told, by the stage they are at: where they stand and what happens next. */
|
||||
public static String kycMessage(final LocalDate expiry, final long offset) {
|
||||
final String expired = expiry.format(DATE);
|
||||
final String freeze = expiry.plusDays(DEBIT_FREEZE_DAYS).format(DATE);
|
||||
final String inoperative = expiry.plusDays(INOPERATIVE_DAYS).format(DATE);
|
||||
final String freezeIn = days(DEBIT_FREEZE_DAYS - offset);
|
||||
final String inoperativeIn = days(INOPERATIVE_DAYS - offset);
|
||||
if (offset < 0) {
|
||||
return "Your KYC expires on " + expired + " (in " + days(-offset) + "). Please visit your branch with valid ID and address"
|
||||
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze + " (in " + freezeIn
|
||||
+ ") and the account will become inoperative on " + inoperative + " (in " + inoperativeIn + ").";
|
||||
return "Your KYC expires on " + expired + " (in " + (-offset) + " days). Please visit your branch with valid ID and address"
|
||||
+ " proof to renew it. If it is not renewed, debits on your account will stop on " + freeze
|
||||
+ " and the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
if (offset < DEBIT_FREEZE_DAYS) {
|
||||
return "Your KYC expired on " + expired + ". Please renew it at your branch now. If it is not renewed, debits on your"
|
||||
+ " account will stop on " + freeze + " (in " + freezeIn + ") and the account will become inoperative on "
|
||||
+ inoperative + " (in " + inoperativeIn + ").";
|
||||
+ " account will stop on " + freeze + " and the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
if (offset < INOPERATIVE_DAYS) {
|
||||
return "Your KYC expired on " + expired + " and debits on your account are now stopped. Please renew your KYC at your"
|
||||
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + " (in " + inoperativeIn
|
||||
+ ").";
|
||||
+ " branch; if it is not renewed, the account will become inoperative on " + inoperative + ".";
|
||||
}
|
||||
return "Your KYC expired on " + expired + " and your account is now inoperative. Please visit your branch with valid"
|
||||
+ " documents to renew your KYC and have the account restored.";
|
||||
}
|
||||
|
||||
private static String days(final long n) {
|
||||
return n == 1 ? "1 day" : n + " days";
|
||||
}
|
||||
|
||||
// ── Inactive / dormant / escheated accounts ────────────────────────────────
|
||||
|
||||
public static String dormantSubject(final DormantAccountData account) {
|
||||
@@ -174,7 +166,7 @@ public class KycNoticeComposer {
|
||||
if (sub != null && sub == SUB_STATUS_DORMANT) {
|
||||
final String escheat = account.getDaysToEscheat() != null && account.getDaysToEscheat() > 0
|
||||
? " If it stays dormant, the balance will be transferred to RBI's Depositor Education and Awareness Fund on "
|
||||
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + " (in " + days(account.getDaysToEscheat()) + ")."
|
||||
+ today.plusDays(account.getDaysToEscheat()).format(DATE) + "."
|
||||
: "";
|
||||
return "Your account " + account.getAccountNo() + " is dormant: there has been no transaction by you since " + since
|
||||
+ ", so debits are stopped. To reactivate it, please visit your branch with valid KYC documents to verify your"
|
||||
@@ -182,7 +174,7 @@ public class KycNoticeComposer {
|
||||
}
|
||||
final String dormant = account.getDaysToDormancy() != null && account.getDaysToDormancy() > 0
|
||||
? " If there is still no transaction, it will become dormant on " + today.plusDays(account.getDaysToDormancy()).format(DATE)
|
||||
+ " (in " + days(account.getDaysToDormancy()) + ") and debits will be stopped."
|
||||
+ " and debits will be stopped."
|
||||
: "";
|
||||
return "Your account " + account.getAccountNo() + " is inactive: there has been no transaction by you since " + since
|
||||
+ ". Please make a transaction or visit your branch." + dormant;
|
||||
|
||||
+2
-5
@@ -36,11 +36,8 @@ public interface KycNoticeQueueService {
|
||||
record QueueItem(Long clientId, String noticeType, String channel, String accountNo, String stageLabel) {
|
||||
}
|
||||
|
||||
/**
|
||||
* How many were queued, how many were left out because the same notice was already waiting, and how many because the
|
||||
* customer has no mobile number or email address to send it to.
|
||||
*/
|
||||
record EnqueueResult(int queued, int skipped, int unreachable) {
|
||||
/** How many were queued, and how many were left out because the same notice was already waiting. */
|
||||
record EnqueueResult(int queued, int skipped) {
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-12
@@ -78,7 +78,6 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
||||
|
||||
int queued = 0;
|
||||
int skipped = 0;
|
||||
int unreachable = 0;
|
||||
for (final QueueItem item : items) {
|
||||
final KycContactChannel channel = item.channel() == null ? null : KycContactChannel.fromString(item.channel());
|
||||
if (item.clientId() == null || channel == null || !channel.isPlatformSent()) {
|
||||
@@ -88,16 +87,6 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
||||
if (!KycNoticeComposer.TYPE_KYC.equals(type) && !KycNoticeComposer.TYPE_DORMANCY.equals(type)) {
|
||||
throw validationError("noticeType", "Notice type must be KYC or DORMANCY.");
|
||||
}
|
||||
// A customer with no mobile number (or no email, for Email) cannot be sent this. Queuing it would only write a
|
||||
// "not sent" record every time, so it is left out and counted — those customers are found and reached another way.
|
||||
final Map<String, Object> contact = this.jdbcTemplate.queryForList("SELECT mobile_no, email_address FROM m_client WHERE id = ?",
|
||||
item.clientId()).stream().findFirst().orElse(null);
|
||||
final String destination = contact == null ? null
|
||||
: (String) contact.get(channel == KycContactChannel.EMAIL ? "email_address" : "mobile_no");
|
||||
if (StringUtils.isBlank(destination)) {
|
||||
unreachable++;
|
||||
continue;
|
||||
}
|
||||
// The same notice already waiting is not queued twice; a double click must not message a customer twice.
|
||||
final Integer waiting = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_kyc_notice_queue WHERE client_id = ? "
|
||||
+ "AND channel = ? AND notice_type = ? AND status IN ('" + PENDING + "', '" + SENDING + "')", Integer.class,
|
||||
@@ -113,7 +102,7 @@ public class KycNoticeQueueServiceImpl implements KycNoticeQueueService {
|
||||
Timestamp.valueOf(when), Timestamp.valueOf(now));
|
||||
queued++;
|
||||
}
|
||||
return new EnqueueResult(queued, skipped, unreachable);
|
||||
return new EnqueueResult(queued, skipped);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+2
-14
@@ -57,15 +57,7 @@ import org.springframework.stereotype.Service;
|
||||
public class KycReminderServiceImpl implements KycReminderService {
|
||||
|
||||
/** Days from expiry (negative = before) at which a customer's KYC stage changes. */
|
||||
/**
|
||||
* Days from expiry (negative = before) at which a KYC reminder goes out: 30, 15, 7 and 1 day before it expires, the
|
||||
* day it expires, then 7 and 1 day before debits stop (day 90) and before the account goes inoperative (day 180), and
|
||||
* on each of those two days themselves.
|
||||
*/
|
||||
private static final int[] KYC_MILESTONES = { -30, -15, -7, -1, 0, 83, 89, 90, 173, 179, 180 };
|
||||
|
||||
/** An account is warned 7 days and 1 day before it moves to its next stage. */
|
||||
private static final long WARN_DAYS = 7;
|
||||
private static final int[] KYC_MILESTONES = { -30, -15, -7, 0, 80, 90, 170, 180 };
|
||||
|
||||
private static final int SUB_STATUS_INACTIVE = 100;
|
||||
private static final int SUB_STATUS_DORMANT = 200;
|
||||
@@ -145,11 +137,7 @@ public class KycReminderServiceImpl implements KycReminderService {
|
||||
int attempted = 0;
|
||||
for (final DormantAccountData account : worst.values()) {
|
||||
try {
|
||||
// The step is "E" (the account has just entered this stage), "D7" (a week before the next) or "D1" (the day
|
||||
// before). The latest one reached is what the customer is told, once.
|
||||
final Long remaining = account.getSubStatus() == SUB_STATUS_DORMANT ? account.getDaysToEscheat() : account.getDaysToDormancy();
|
||||
final String step = remaining != null && remaining <= 1 ? "D1" : remaining != null && remaining <= WARN_DAYS ? "D7" : "E";
|
||||
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + " " + step + "]";
|
||||
final String marker = "[AUTO DORMANCY S" + account.getSubStatus() + " " + account.getLastCustomerTransactionDate() + "]";
|
||||
final String subject = KycNoticeComposer.dormantSubject(account);
|
||||
attempted += sendBoth(account.getClientId(), marker, subject, KycNoticeComposer.dormantMessage(account, today));
|
||||
} catch (final RuntimeException e) {
|
||||
|
||||
+3
-1
@@ -93,7 +93,9 @@ public class KycRiskRulesReadPlatformServiceImpl implements KycRiskRulesReadPlat
|
||||
txnRule != null ? txnRule.getThresholdAmountHighRisk() : null,
|
||||
txnRule != null ? txnRule.getMinLimitAmount() : null,
|
||||
txnRule != null ? txnRule.getMaxLimitAmount() : null,
|
||||
rule.isCcRelevant()
|
||||
rule.isCcRelevant(),
|
||||
rule.isOnTrial(),
|
||||
rule.getTrialStartedOn() == null ? null : rule.getTrialStartedOn().toString()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -31,4 +31,7 @@ public interface KycRiskRulesWritePlatformService {
|
||||
* @return the persisted rule exactly as the catalog GET would return it
|
||||
*/
|
||||
KycRiskRuleData createRule(KycRiskRuleRequest request);
|
||||
|
||||
/** Ends a rule's trial: it stays in the catalog as a normal rule. Returns the rule as the catalog GET would. */
|
||||
KycRiskRuleData markNormal(Long ruleId);
|
||||
}
|
||||
|
||||
+27
-2
@@ -28,6 +28,7 @@ import org.apache.fineract.infrastructure.codes.domain.CodeValueRepositoryWrappe
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlAppliesTo;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlRuleType;
|
||||
import org.apache.fineract.portfolio.aml.domain.AmlTransactionRule;
|
||||
@@ -81,6 +82,13 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
rule.setOccupation(occupation);
|
||||
}
|
||||
|
||||
// A rule written for a Cash Credit account, or marked so, is listed under the CC chip.
|
||||
rule.markCcRelevant(Boolean.TRUE.equals(request.getCcRelevant())
|
||||
|| "CURRENT".equalsIgnoreCase(StringUtils.defaultString(request.getTxnAppliesTo()).trim()));
|
||||
|
||||
// A rule authored here starts on trial, from the business date, until an officer marks it normal.
|
||||
rule.startTrial(DateUtils.getBusinessLocalDate());
|
||||
|
||||
final ClientRiskRule saved = this.repository.saveAndFlush(rule);
|
||||
|
||||
if (request.hasTransactionRule()) {
|
||||
@@ -138,8 +146,21 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
final AmlAppliesTo appliesTo = StringUtils.isBlank(request.getTxnAppliesTo()) ? AmlAppliesTo.ALL
|
||||
: AmlAppliesTo.valueOf(request.getTxnAppliesTo().trim().toUpperCase());
|
||||
|
||||
return AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo, ruleType,
|
||||
request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
final AmlTransactionRule transactionRule = AmlTransactionRule.createForRiskRule(saved.getId(), saved.getReason(), appliesTo,
|
||||
ruleType, request.getTxnWindowDays(), threshold, request.getTxnMinLimitAmount(), request.getTxnMaxLimitAmount());
|
||||
transactionRule.restrictTo(StringUtils.isBlank(request.getTxnDirection()) ? null : request.getTxnDirection().trim().toUpperCase(),
|
||||
Boolean.TRUE.equals(request.getTxnCashOnly()));
|
||||
return transactionRule;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional
|
||||
public KycRiskRuleData markNormal(final Long ruleId) {
|
||||
final ClientRiskRule rule = this.repository.findById(ruleId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Client risk rule " + ruleId + " not found"));
|
||||
rule.markNormal();
|
||||
this.repository.saveAndFlush(rule);
|
||||
return this.readPlatformService.retrieveRule(ruleId);
|
||||
}
|
||||
|
||||
private Integer nextOrderPosition() {
|
||||
@@ -182,6 +203,10 @@ public class KycRiskRulesWritePlatformServiceImpl implements KycRiskRulesWritePl
|
||||
|
||||
validateEnum(validator, "txnRuleType", request.getTxnRuleType(), AmlRuleType.class);
|
||||
validateEnum(validator, "txnAppliesTo", request.getTxnAppliesTo(), AmlAppliesTo.class);
|
||||
if (StringUtils.isNotBlank(request.getTxnDirection()) && !"CREDIT".equalsIgnoreCase(request.getTxnDirection().trim())
|
||||
&& !"DEBIT".equalsIgnoreCase(request.getTxnDirection().trim())) {
|
||||
validator.reset().parameter("txnDirection").value(request.getTxnDirection()).failWithCode("must.be.credit.or.debit");
|
||||
}
|
||||
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
|
||||
+2
-36
@@ -137,19 +137,6 @@ public class LoanOriginationPipelineApiResource {
|
||||
// the updated application, so the client replaces its copy rather than guessing the result.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/deposit-selection-complete")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Complete Deposit Selection & Eligibility",
|
||||
description = "Loan against a deposit only. Requires a saved deposit selection that covers the requested amount "
|
||||
+ "and tenure; the credit bureau stage is skipped.")
|
||||
public String completeDepositSelection(@PathParam("applicationNo") final String applicationNo,
|
||||
final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.completeDepositSelection(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/kyc-complete")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@@ -252,29 +239,8 @@ public class LoanOriginationPipelineApiResource {
|
||||
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
|
||||
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
||||
final String apiRequestBodyAsJson) {
|
||||
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson,
|
||||
this.context.authenticatedUser().getUsername());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/approvals/{field}/submit")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Send a stage for approval",
|
||||
description = "Maker step. The stage's checklist must be complete; a different user then approves or rejects it.")
|
||||
public String submitApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
||||
final String apiRequestBodyAsJson) {
|
||||
return this.stageService.submitApproval(applicationNo, field, apiRequestBodyAsJson,
|
||||
this.context.authenticatedUser().getUsername());
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/approvals/{field}/withdraw")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Withdraw an approval request", description = "Only the user who sent the request can withdraw it.")
|
||||
public String withdrawApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field) {
|
||||
return this.stageService.withdrawApproval(applicationNo, field, this.context.authenticatedUser().getUsername());
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
package org.apache.fineract.portfolio.loanoriginationpipeline.service;
|
||||
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.fineract.infrastructure.event.business.BusinessEventListener;
|
||||
import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent;
|
||||
import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService;
|
||||
import org.apache.fineract.portfolio.loanaccount.domain.Loan;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* A Daily Deposit Scheme account pledged for a loan is held with a lien whose reason names the loan
|
||||
* ({@link #reasonFor}). The DDS module and the loan module are not connected, so nothing else would ever lift
|
||||
* that hold: this lifts it when the loan stops being live (repaid, closed, written off, withdrawn).
|
||||
*
|
||||
* <p>
|
||||
* FD and RD liens need nothing like this: they are Fineract guarantor funding, which Fineract releases itself as the
|
||||
* loan is repaid.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class DdsLienReleaseService {
|
||||
|
||||
private static final String LIEN_PREFIX = "Held for loan ";
|
||||
|
||||
private final BusinessEventNotifierService businessEventNotifierService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The text written on the DDS account's lien, and what is matched when the loan ends. */
|
||||
public static String reasonFor(final String loanAccountNo, final String applicationNo) {
|
||||
return LIEN_PREFIX + loanAccountNo + " (" + applicationNo + ")";
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
public void addListeners() {
|
||||
this.businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class,
|
||||
new ReleaseOnLoanEnd());
|
||||
}
|
||||
|
||||
private final class ReleaseOnLoanEnd implements BusinessEventListener<LoanStatusChangedBusinessEvent> {
|
||||
|
||||
@Override
|
||||
public void onBusinessEvent(final LoanStatusChangedBusinessEvent event) {
|
||||
final Loan loan = event.get();
|
||||
if (loan == null || loan.getStatus() == null || loan.getStatus().isActiveOrAwaitingApprovalOrDisbursal()) {
|
||||
return;
|
||||
}
|
||||
final int released = jdbcTemplate.update(
|
||||
"UPDATE m_dds_party SET lien = FALSE, lien_reason = NULL WHERE lien = TRUE AND lien_reason LIKE ?",
|
||||
LIEN_PREFIX + loan.getAccountNumber() + " (%");
|
||||
if (released > 0) {
|
||||
log.info("Released the lien on {} DDS account(s) held for loan {}", released, loan.getAccountNumber());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-52
@@ -185,58 +185,7 @@ public class LoanOriginationPipelineService {
|
||||
*/
|
||||
@Transactional
|
||||
public String save(final String applicationNo, final String requestBodyAsJson) {
|
||||
final JsonObject stored = loadApplication(applicationNo);
|
||||
// Once a stage is sent for approval nothing on the application may change until it is decided or withdrawn,
|
||||
// so a full-object save is ignored and the stored application is returned as it is.
|
||||
if (hasPendingApprovalRequest(stored)) {
|
||||
return findOneAsJson(applicationNo);
|
||||
}
|
||||
// A disbursed or rejected application is final. A screen that was opened before that happened still holds the
|
||||
// older copy, and saving it back would undo the disbursement (and lose the link to the loan account).
|
||||
final String storedStatus = stringOrNull(stored, "overallStatus");
|
||||
if ("DISBURSED".equals(storedStatus) || "REJECTED".equals(storedStatus)) {
|
||||
return findOneAsJson(applicationNo);
|
||||
}
|
||||
final JsonObject incoming = parseObject(requestBodyAsJson);
|
||||
// A copy that is further back than what is stored is a stale screen: another user or a stage endpoint has moved the
|
||||
// application on since it was opened. Its save is ignored and the stored application is returned, so the screen
|
||||
// picks up the real state instead of overwriting it.
|
||||
if (furthestStageOf(incoming) < furthestStageOf(stored)) {
|
||||
return findOneAsJson(applicationNo);
|
||||
}
|
||||
// What only the server decides (approval decisions and requests, the loan account it was disbursed to) is never
|
||||
// taken from the screen's copy.
|
||||
for (final String key : SERVER_OWNED_FIELDS) {
|
||||
if (stored.has(key)) {
|
||||
incoming.add(key, stored.get(key).deepCopy());
|
||||
} else {
|
||||
incoming.remove(key);
|
||||
}
|
||||
}
|
||||
return storeApplication(applicationNo, incoming);
|
||||
}
|
||||
|
||||
private static final java.util.List<String> SERVER_OWNED_FIELDS = java.util.List.of("creditCommitteeApproval", "riskApproval",
|
||||
"legalApproval", "postCheckApproval", "disbursementApproval", "approvalRequests", "fineractLoanId", "fineractLoanAccountNo");
|
||||
|
||||
private int furthestStageOf(final JsonObject app) {
|
||||
final JsonElement value = app.get("furthestStageIndex");
|
||||
return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber() ? value.getAsInt() : 0;
|
||||
}
|
||||
|
||||
private boolean hasPendingApprovalRequest(final JsonObject app) {
|
||||
final JsonElement requests = app.get("approvalRequests");
|
||||
if (requests == null || !requests.isJsonObject()) {
|
||||
return false;
|
||||
}
|
||||
for (final java.util.Map.Entry<String, JsonElement> entry : requests.getAsJsonObject().entrySet()) {
|
||||
final JsonElement request = entry.getValue();
|
||||
if (request.isJsonObject() && request.getAsJsonObject().has("status")
|
||||
&& "PENDING".equals(request.getAsJsonObject().get("status").getAsString())) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
|
||||
}
|
||||
|
||||
/** Writes a whole application object back. Shared by the full-object save and every per-stage
|
||||
|
||||
+35
-440
@@ -36,11 +36,9 @@ import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
@@ -73,59 +71,14 @@ public class LoanOriginationStageService {
|
||||
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
|
||||
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "DEPOSIT_SELECTION", "CIC_CHECK", "FIELD_INVESTIGATION",
|
||||
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION",
|
||||
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
|
||||
"POST_CHECK", "DISBURSED");
|
||||
|
||||
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
|
||||
"shopVerification");
|
||||
|
||||
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security. */
|
||||
private static final Set<String> DEPOSIT_PRODUCT_CODES = Set.of("FD", "RD", "DS", "LI");
|
||||
|
||||
private final LoanOriginationPipelineService pipelineService;
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** A loan above the product's limit on the deposit (85%) is allowed only as a recorded officer's discretion with a reason. */
|
||||
private void requireWithinLimitOrException(final JsonObject app, final JsonObject selection) {
|
||||
final double value = selection.has("securityValue") ? selection.get("securityValue").getAsDouble() : 0;
|
||||
final double limitPct = selection.has("limitPct") ? selection.get("limitPct").getAsDouble() : 85;
|
||||
final double requested = intOr(app, "requestedAmount", 0);
|
||||
if (requested > value * limitPct / 100 + 0.5) {
|
||||
require(bool(selection, "over85") && !strOr(selection, "over85Reason", "").isBlank(),
|
||||
"The loan is above " + (int) limitPct + "% of the deposit; record the reason for sanctioning above the limit.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Product Creation sub types of the loans against a deposit or policy. */
|
||||
private static final Set<String> DEPOSIT_SUB_TYPES = Set.of("fd", "rd", "dds", "lic");
|
||||
|
||||
/**
|
||||
* A loan against a deposit is told by the product's own sub type (chosen in Product Creation), so a product made
|
||||
* later under any short name (a second FD product, say) follows the deposit flow. The fixed short names stay as
|
||||
* the answer for the original products.
|
||||
*/
|
||||
private boolean isDepositLoan(final JsonObject app) {
|
||||
final String code = str(app, "productCode");
|
||||
if (code == null) {
|
||||
return false;
|
||||
}
|
||||
return DEPOSIT_PRODUCT_CODES.contains(code) || DEPOSIT_SUB_TYPES.contains(productSubType(code));
|
||||
}
|
||||
|
||||
private boolean isLicLoan(final JsonObject app) {
|
||||
final String code = str(app, "productCode");
|
||||
return code != null && ("LI".equals(code) || "lic".equals(productSubType(code)));
|
||||
}
|
||||
|
||||
/** The sub type saved with the loan product of this short name; empty when the product has none. */
|
||||
private String productSubType(final String shortName) {
|
||||
final List<String> found = jdbcTemplate.queryForList(
|
||||
"select e.product_type_sub_group from m_product_loan p join m_product_loan_advance_ext e on e.loan_product_id = p.id"
|
||||
+ " where p.short_name = ?",
|
||||
String.class, shortName);
|
||||
return found.isEmpty() || found.get(0) == null ? "" : found.get(0);
|
||||
}
|
||||
|
||||
// ── KYC & Documents ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -137,83 +90,16 @@ public class LoanOriginationStageService {
|
||||
|
||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||
requirePurposeAndPriorityClassification(app);
|
||||
final boolean depositLoan = isDepositLoan(app);
|
||||
if (!depositLoan) {
|
||||
// For a loan against a deposit these are optional: the deposit is the security.
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
|
||||
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
|
||||
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
|
||||
}
|
||||
for (final JsonElement element : arr(app, "productDocuments")) {
|
||||
final JsonObject doc = element.getAsJsonObject();
|
||||
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||
}
|
||||
|
||||
if (app.has("guarantorsRequiredCount")) {
|
||||
requireGuarantorsNamed(app);
|
||||
}
|
||||
|
||||
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
|
||||
if (!depositLoan) {
|
||||
// Deposit selection only exists for a loan against a deposit; every other product passes straight through.
|
||||
advance(app, actor(input), "Deposit Selection Not Required", "Not a loan against a deposit.");
|
||||
}
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every loan states what it is for and how it is classified under the RBI priority sector lending directions before
|
||||
* KYC is complete: the purpose, the priority sector decision (with its category when it is priority sector) and
|
||||
* whether the borrower is a weaker section (with its type when it is).
|
||||
*/
|
||||
private void requirePurposeAndPriorityClassification(final JsonObject app) {
|
||||
require(!strOr(app, "loanPurpose", "").isBlank(), "The purpose of the loan has not been entered.");
|
||||
final String priority = strOr(app, "pslClassified", "");
|
||||
require("yes".equals(priority) || "no".equals(priority), "The priority sector decision has not been made.");
|
||||
if ("yes".equals(priority)) {
|
||||
require(!strOr(app, "prioritySectorCategory", "").isBlank(), "The priority sector category has not been chosen.");
|
||||
}
|
||||
final String weaker = strOr(app, "weakerSection", "");
|
||||
require("yes".equals(weaker) || "no".equals(weaker), "Whether the borrower is a weaker section has not been decided.");
|
||||
if ("yes".equals(weaker)) {
|
||||
require(!strOr(app, "weakerSectionType", "").isBlank(), "The weaker section type has not been chosen.");
|
||||
}
|
||||
}
|
||||
|
||||
// ── Deposit Selection & Eligibility (loan against FD / RD / DDS / LIC) ───
|
||||
|
||||
@Transactional
|
||||
public String completeDepositSelection(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "DEPOSIT_SELECTION");
|
||||
require(isDepositLoan(app), "Deposit selection applies only to a loan against a deposit.");
|
||||
|
||||
final JsonObject selection = obj(app, "depositSelection");
|
||||
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
|
||||
if (isLicLoan(app)) {
|
||||
// A LIC policy is not a bank account: it is recorded on the application from the policy documents.
|
||||
final JsonObject policy = obj(selection, "policy");
|
||||
require(policy != null && !strOr(policy, "policyNo", "").isBlank(), "The LIC policy number has not been recorded.");
|
||||
require(intOr(policy, "surrenderValue", 0) > 0, "The surrender value of the policy has not been recorded.");
|
||||
require(!strOr(policy, "maturityDate", "").isBlank(), "The maturity date of the policy has not been recorded.");
|
||||
} else {
|
||||
require(!arr(selection, "accountIds").isEmpty(), "Select at least one deposit.");
|
||||
}
|
||||
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
|
||||
"The requested amount is more than the eligible amount on the selected deposits.");
|
||||
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
|
||||
"The tenure runs past the maturity of the selected deposits.");
|
||||
requireWithinLimitOrException(app, selection);
|
||||
|
||||
advance(app, actor(input), "Deposit Selection Complete",
|
||||
strOr(input, "remarks", "Deposit selected and eligibility confirmed."));
|
||||
// No credit bureau check on a loan against a deposit: the stage is recorded as not required and skipped.
|
||||
// The deposit is the security: no credit bureau check and no field investigation. Every other stage stays.
|
||||
advance(app, actor(input), "CIC Not Required", "Loan against a deposit - no credit bureau check.");
|
||||
advance(app, actor(input), "Field Investigation Not Required", "Loan against a deposit - no field investigation.");
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@@ -300,8 +186,7 @@ public class LoanOriginationStageService {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "PERSONAL_DISCUSSION");
|
||||
// A loan against a deposit has no meeting to schedule: the loan plan is reviewed instead.
|
||||
require(isDepositLoan(app) || bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
|
||||
require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
|
||||
|
||||
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
|
||||
app.addProperty("discrepancyType", "NONE");
|
||||
@@ -362,247 +247,74 @@ public class LoanOriginationStageService {
|
||||
*
|
||||
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
|
||||
*/
|
||||
private String approvalStage(final String field) {
|
||||
return switch (field) {
|
||||
@Transactional
|
||||
public String decideApproval(final String applicationNo, final String field, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
|
||||
final String decision = strOr(input, "decision", "APPROVED");
|
||||
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
|
||||
|
||||
final String expectedStage = switch (field) {
|
||||
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
|
||||
case "riskApproval" -> "RISK_APPROVAL";
|
||||
case "legalApproval" -> "LEGAL_APPROVAL";
|
||||
case "postCheckApproval" -> "POST_CHECK";
|
||||
// Sending the disbursement for approval: it is asked at the Disbursement stage and does not move the application on.
|
||||
case "disbursementApproval" -> "DISBURSED";
|
||||
default -> throw badRequest("Unknown approval stage: " + field);
|
||||
};
|
||||
}
|
||||
requireStage(app, expectedStage);
|
||||
|
||||
private void checkApprovalPrerequisites(final JsonObject app, final String field, final JsonObject input) {
|
||||
// Prerequisites are only enforced for a positive decision: a rejection has to remain possible
|
||||
// at any point, and blocking it on an incomplete checklist would trap the application.
|
||||
if (!"REJECTED".equals(decision)) {
|
||||
switch (field) {
|
||||
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
|
||||
case "riskApproval" -> requireRiskChecklistReady(app);
|
||||
case "legalApproval" -> requireLegalChecklistDone(app);
|
||||
case "postCheckApproval" -> requirePostCheckSignatures(app);
|
||||
case "disbursementApproval" -> requireSanctionApprovalsStanding(app);
|
||||
default -> throw badRequest("Unknown approval stage: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
/** The four sanctioning approvals must all stand before a disbursement can be sent for approval. */
|
||||
private void requireSanctionApprovalsStanding(final JsonObject app) {
|
||||
final List<String> fields = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval")
|
||||
: List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval");
|
||||
for (final String field : fields) {
|
||||
final JsonObject approval = obj(app, field);
|
||||
final String decision = approval == null ? null : str(approval, "decision");
|
||||
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision), "Cannot send for disbursement approval: " + field + " is not approved.");
|
||||
}
|
||||
}
|
||||
|
||||
private JsonObject pendingRequest(final JsonObject app, final String field) {
|
||||
final JsonObject requests = obj(app, "approvalRequests");
|
||||
final JsonObject request = requests == null ? null : obj(requests, field);
|
||||
return request != null && "PENDING".equals(str(request, "status")) ? request : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker step: the stage's checklist is complete, and the decision is sent to someone else. Nothing advances until
|
||||
* a different user approves it.
|
||||
*/
|
||||
@Transactional
|
||||
public String submitApproval(final String applicationNo, final String field, final String body, final String user) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
final String expectedStage = approvalStage(field);
|
||||
requireStage(app, expectedStage);
|
||||
require(pendingRequest(app, field) == null, "This stage has already been sent for approval.");
|
||||
|
||||
final String requested = strOr(input, "decision", "APPROVED");
|
||||
require(List.of("APPROVED", "EXCEPTION_APPROVED").contains(requested), "Unknown decision: " + requested);
|
||||
checkApprovalPrerequisites(app, field, input);
|
||||
|
||||
final String remarks = strOr(input, "remarks", "Sent for approval.");
|
||||
final JsonObject request = new JsonObject();
|
||||
request.addProperty("status", "PENDING");
|
||||
request.addProperty("submittedBy", user);
|
||||
request.addProperty("submittedOn", today());
|
||||
request.addProperty("requestedDecision", requested);
|
||||
request.addProperty("remarks", remarks);
|
||||
request.addProperty("resolutionNo", strOr(input, "resolutionNo", ""));
|
||||
request.addProperty("resolutionDate", strOr(input, "resolutionDate", ""));
|
||||
// What the sender had verified when sending: kept as it was, for the approver to review.
|
||||
if (input.has("checklist") && input.get("checklist").isJsonArray()) {
|
||||
request.add("checklist", input.getAsJsonArray("checklist"));
|
||||
}
|
||||
final JsonObject oldRequests = obj(app, "approvalRequests");
|
||||
final JsonObject oldRequest = oldRequests == null ? null : obj(oldRequests, field);
|
||||
if (oldRequest != null && "REVOKED".equals(str(oldRequest, "status"))) {
|
||||
request.addProperty("previousRevokeRemarks", strOr(oldRequest, "revokeRemarks", str(oldRequest, "remarks")));
|
||||
request.addProperty("previousRevokedBy", str(oldRequest, "decidedBy"));
|
||||
request.addProperty("previousRevokedOn", str(oldRequest, "decidedOn"));
|
||||
}
|
||||
objOrCreate(app, "approvalRequests").add(field, request);
|
||||
|
||||
appendHistory(app, expectedStage, user, "Sent for Approval", remarks);
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String withdrawApproval(final String applicationNo, final String field, final String user) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final String expectedStage = approvalStage(field);
|
||||
requireStage(app, expectedStage);
|
||||
final JsonObject request = pendingRequest(app, field);
|
||||
require(request != null, "There is no pending approval request on this stage.");
|
||||
require(user.equalsIgnoreCase(str(request, "submittedBy")), "Only the person who sent the request can withdraw it.");
|
||||
request.addProperty("status", "WITHDRAWN");
|
||||
appendHistory(app, expectedStage, user, "Approval Request Withdrawn", "Withdrawn by " + user + ".");
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
/**
|
||||
* The approver has to have ticked every row the sender verified, and the sender must not have left any row
|
||||
* unverified. Requests sent before the checklist was recorded are not held to this.
|
||||
*/
|
||||
private void requireCheckerVerified(final JsonObject request, final JsonObject input) {
|
||||
if (!request.has("checklist") || !request.get("checklist").isJsonArray()) {
|
||||
return;
|
||||
}
|
||||
final java.util.Set<String> ticked = new java.util.HashSet<>();
|
||||
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
|
||||
for (final JsonElement element : input.getAsJsonArray("checkerChecklist")) {
|
||||
final JsonObject row = element.getAsJsonObject();
|
||||
if (bool(row, "ok")) {
|
||||
ticked.add(str(row, "label"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (final JsonElement element : request.getAsJsonArray("checklist")) {
|
||||
final JsonObject row = element.getAsJsonObject();
|
||||
final String label = strOr(row, "label", "item");
|
||||
require(bool(row, "ok"), "Cannot approve: the sender did not verify \"" + label + "\".");
|
||||
require(ticked.contains(label), "Tick every row you have verified before approving: \"" + label + "\".");
|
||||
}
|
||||
}
|
||||
|
||||
/** Checker step: a different user approves, rejects or revokes (returns for rectification) what was sent. */
|
||||
@Transactional
|
||||
public String decideApproval(final String applicationNo, final String field, final String body, final String user) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
|
||||
String decision = strOr(input, "decision", "APPROVED");
|
||||
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED", "REVOKED").contains(decision), "Unknown decision: " + decision);
|
||||
|
||||
final String expectedStage = approvalStage(field);
|
||||
requireStage(app, expectedStage);
|
||||
|
||||
final JsonObject request = pendingRequest(app, field);
|
||||
require(request != null, "This stage has not been sent for approval yet.");
|
||||
require(!user.equalsIgnoreCase(str(request, "submittedBy")),
|
||||
"The person who sent this for approval cannot approve, reject or revoke it. Ask another user.");
|
||||
|
||||
String remarks = strOr(input, "remarks", "").trim();
|
||||
if ("REVOKED".equals(decision)) {
|
||||
require(!remarks.isBlank(), "Remarks are required when returning an approval request for correction.");
|
||||
} else if ("REJECTED".equals(decision)) {
|
||||
require(!remarks.isBlank(), "Give the reason for rejecting in the remarks box.");
|
||||
}
|
||||
|
||||
if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||
decision = strOr(request, "requestedDecision", "APPROVED");
|
||||
requireCheckerVerified(request, input);
|
||||
}
|
||||
|
||||
// Prerequisites are only enforced for a positive decision: a rejection or revocation has to remain possible
|
||||
// at any point, and blocking it on an incomplete checklist would trap the application.
|
||||
final JsonObject resolution = new JsonObject();
|
||||
resolution.addProperty("resolutionNo", strOr(request, "resolutionNo", ""));
|
||||
resolution.addProperty("resolutionDate", strOr(request, "resolutionDate", ""));
|
||||
if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||
checkApprovalPrerequisites(app, field, resolution);
|
||||
}
|
||||
|
||||
if (remarks.isBlank()) {
|
||||
remarks = "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.";
|
||||
}
|
||||
|
||||
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision) && !"REVOKED".equals(decision)) {
|
||||
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
|
||||
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
|
||||
// The resolution number and date are what the sanction letter later quotes, so they are
|
||||
// recorded on the application and restated in the audit trail.
|
||||
final boolean depositSanction = isDepositLoan(app);
|
||||
final String resolutionNo = depositSanction ? applicationNo : strOr(resolution, "resolutionNo", "");
|
||||
final String resolutionDate = depositSanction ? today() : strOr(resolution, "resolutionDate", "");
|
||||
final String resolutionNo = strOr(input, "resolutionNo", "");
|
||||
final String resolutionDate = strOr(input, "resolutionDate", "");
|
||||
app.addProperty("committeeResolutionNo", resolutionNo);
|
||||
app.addProperty("committeeResolutionDate", resolutionDate);
|
||||
remarks = remarks + (depositSanction ? " (Sanction No. " : " (Resolution No. ") + resolutionNo + " dated " + resolutionDate + ")";
|
||||
}
|
||||
|
||||
if ("REVOKED".equals(decision)) {
|
||||
request.addProperty("status", "REVOKED");
|
||||
request.addProperty("decidedBy", user);
|
||||
request.addProperty("decidedOn", today());
|
||||
request.addProperty("revokeRemarks", remarks);
|
||||
request.addProperty("remarks", remarks);
|
||||
if (app.has(field)) {
|
||||
app.remove(field);
|
||||
}
|
||||
appendHistory(app, expectedStage, user, "Returned for Rectification", remarks);
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
|
||||
}
|
||||
|
||||
final JsonObject approval = objOrCreate(app, field);
|
||||
approval.addProperty("decision", decision);
|
||||
approval.addProperty("approverName", user);
|
||||
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
|
||||
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
|
||||
approval.addProperty("date", today());
|
||||
approval.addProperty("remarks", remarks);
|
||||
|
||||
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
|
||||
request.add("checkerChecklist", input.getAsJsonArray("checkerChecklist"));
|
||||
}
|
||||
request.addProperty("status", decision);
|
||||
request.addProperty("decidedBy", user);
|
||||
request.addProperty("decidedOn", today());
|
||||
|
||||
if ("disbursementApproval".equals(field)) {
|
||||
// Approving the disbursement does not move the stage (the loan is disbursed next); declining it only sends it back.
|
||||
if ("REJECTED".equals(decision)) {
|
||||
app.remove(field);
|
||||
appendHistory(app, expectedStage, user, "Disbursement Declined", remarks);
|
||||
} else {
|
||||
appendHistory(app, expectedStage, user, "Disbursement Approved", remarks);
|
||||
}
|
||||
} else if ("REJECTED".equals(decision)) {
|
||||
app.addProperty("overallStatus", "REJECTED");
|
||||
appendHistory(app, str(app, "currentStage"), user, "Rejected", remarks);
|
||||
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
|
||||
} else {
|
||||
advance(app, user, "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
||||
if ("creditCommitteeApproval".equals(field) && isDepositLoan(app)) {
|
||||
// Sanctioned: no risk approval and no legal approval on a loan against a deposit (the legal documents are taken at signing).
|
||||
advance(app, user, "Risk Approval Not Required", "Loan against a deposit - sanctioned by the Sanction step.");
|
||||
advance(app, user, "Legal Approval Not Required", "Loan against a deposit - the legal documents are taken at Customer Signing.");
|
||||
}
|
||||
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
||||
}
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public String markSigned(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "CUSTOMER_SIGNING");
|
||||
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
|
||||
if (isDepositLoan(app)) {
|
||||
// No legal approval stage on a loan against a deposit: its legal documents (stamp duty, DP note, lien letter ...) are taken here.
|
||||
requireLegalChecklistDone(app);
|
||||
}
|
||||
|
||||
app.addProperty("customerSigningDate", today());
|
||||
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
|
||||
strOr(input, "remarks", "Loan agreement signed by the customer."));
|
||||
if (isDepositLoan(app)) {
|
||||
// Recovery of a loan against a deposit is by set-off against the lien (policy 9.5), so no security cheque / ACH mandate.
|
||||
advance(app, actor(input), "Cheque & ACH Not Required",
|
||||
"Loan against a deposit - recovery by set-off against the lien.");
|
||||
}
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@@ -611,12 +323,6 @@ public class LoanOriginationStageService {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "CHEQUE_ACH");
|
||||
if (isDepositLoan(app)) {
|
||||
// An application that was already waiting at this stage before it was made optional for deposit loans.
|
||||
advance(app, actor(input), "Cheque & ACH Not Required",
|
||||
"Loan against a deposit - recovery by set-off against the lien.");
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
|
||||
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
|
||||
|
||||
@@ -637,23 +343,13 @@ public class LoanOriginationStageService {
|
||||
requireStage(app, "DISBURSED");
|
||||
require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed.");
|
||||
|
||||
final List<String> mustStand = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval")
|
||||
: List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval");
|
||||
for (final String field : mustStand) {
|
||||
for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) {
|
||||
final JsonObject approval = obj(app, field);
|
||||
final String decision = approval == null ? null : str(approval, "decision");
|
||||
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
|
||||
"Cannot disburse: " + field + " is not approved.");
|
||||
}
|
||||
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
|
||||
if (isDepositLoan(app)) {
|
||||
final JsonObject selection = obj(app, "depositSelection");
|
||||
// The product decides whether a lien is required (Product Creation > Security & Lien); it defaults to required.
|
||||
final boolean lienRequired = selection == null || !selection.has("lienRequired") || bool(selection, "lienRequired");
|
||||
final JsonObject lien = obj(app, "lien");
|
||||
require(!lienRequired || (lien != null && bool(lien, "marked")),
|
||||
"Cannot disburse: the lien on the pledged deposit has not been marked.");
|
||||
}
|
||||
|
||||
final String mode = strOr(input, "disbursementMode", "Account Credit");
|
||||
app.addProperty("disbursementDate", today());
|
||||
@@ -669,38 +365,12 @@ public class LoanOriginationStageService {
|
||||
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
|
||||
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
|
||||
}
|
||||
holdDdsAccounts(applicationNo, app);
|
||||
|
||||
appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks",
|
||||
"Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
/**
|
||||
* A loan against a Daily Deposit Scheme account puts the account on hold (its lien flag) as it is disbursed, naming the loan
|
||||
* in the reason; {@link DdsLienReleaseService} lifts it when that loan ends. The DDS module is the bank's own, outside
|
||||
* Fineract's deposit accounts, so there is no Fineract call for this. The outcome is written on the application's lien.
|
||||
*/
|
||||
private void holdDdsAccounts(final String applicationNo, final JsonObject app) {
|
||||
if (!"DS".equals(str(app, "productCode"))) {
|
||||
return;
|
||||
}
|
||||
final JsonObject selection = obj(app, "depositSelection");
|
||||
final JsonObject lien = obj(app, "lien");
|
||||
if (selection == null || lien == null || !bool(lien, "marked")) {
|
||||
return;
|
||||
}
|
||||
final String loanNo = filled(app, "fineractLoanAccountNo") ? str(app, "fineractLoanAccountNo") : applicationNo;
|
||||
final String reason = DdsLienReleaseService.reasonFor(loanNo, applicationNo);
|
||||
int held = 0;
|
||||
for (final JsonElement id : arr(selection, "accountIds")) {
|
||||
held += jdbcTemplate.update("UPDATE m_dds_party SET lien = TRUE, lien_reason = ? WHERE id = ? AND status <> 'CLOSED'", reason,
|
||||
id.getAsLong());
|
||||
}
|
||||
lien.addProperty("coreStatus", held > 0 ? "PLACED" : "FAILED");
|
||||
lien.addProperty("coreNote", held > 0 ? "DDS account held: " + reason : "No open DDS account was found to hold.");
|
||||
}
|
||||
|
||||
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
|
||||
|
||||
private void requireFieldInvestigationComplete(final JsonObject app) {
|
||||
@@ -720,95 +390,30 @@ public class LoanOriginationStageService {
|
||||
}
|
||||
|
||||
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
|
||||
final boolean depositLoan = isDepositLoan(app);
|
||||
if (!depositLoan) {
|
||||
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
|
||||
}
|
||||
// On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional.
|
||||
require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
if (bool(app, "secured") && !depositLoan) {
|
||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
|
||||
}
|
||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||
if (!depositLoan) {
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
requireFieldInvestigationComplete(app);
|
||||
}
|
||||
require(depositLoan || bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
||||
if (depositLoan) {
|
||||
// A loan against a deposit has no committee, risk or legal approval: this one Sanction step is all, and it makes the
|
||||
// checks Risk would have made (KYC, the deposit covers the loan, the declarations). No resolution is needed.
|
||||
requireRiskChecklistReady(app);
|
||||
return;
|
||||
}
|
||||
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
||||
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
|
||||
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
|
||||
}
|
||||
|
||||
private void requireRiskChecklistReady(final JsonObject app) {
|
||||
final boolean depositLoan = isDepositLoan(app);
|
||||
if (depositLoan) {
|
||||
// What Risk confirms on a loan against a deposit: KYC done, and the deposit still covers the loan.
|
||||
require(bool(app, "aadhaarVerified") && bool(app, "panVerified"), "KYC (Aadhaar and PAN) is not verified.");
|
||||
final JsonObject selection = obj(app, "depositSelection");
|
||||
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
|
||||
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
|
||||
"The loan is more than the eligible amount on the pledged deposit.");
|
||||
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
|
||||
"The tenure runs past the maturity of the pledged deposit.");
|
||||
requireWithinLimitOrException(app, selection);
|
||||
}
|
||||
if (!depositLoan) {
|
||||
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
}
|
||||
// A loan against a deposit takes no separate declaration: the deposit is the security.
|
||||
require(depositLoan || bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
|
||||
if (bool(app, "secured") && !depositLoan) {
|
||||
require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
|
||||
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
|
||||
}
|
||||
if (!depositLoan) {
|
||||
requireFieldInvestigationComplete(app);
|
||||
}
|
||||
// On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional.
|
||||
require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
requireGuarantorsNamed(app);
|
||||
}
|
||||
|
||||
/**
|
||||
* The guarantors an application has to name. A product that says how many (Product Creation) is held to that count: each
|
||||
* guarantor an onboarded client, none twice, none the borrower, and a bank member unless the product says otherwise. An
|
||||
* application raised before products said so keeps the officer's own Guarantor Required tick and its single guarantor.
|
||||
*/
|
||||
private void requireGuarantorsNamed(final JsonObject app) {
|
||||
final JsonElement count = app.get("guarantorsRequiredCount");
|
||||
if (count != null && count.isJsonPrimitive() && count.getAsJsonPrimitive().isNumber()) {
|
||||
final int need = count.getAsInt();
|
||||
if (need <= 0) {
|
||||
return;
|
||||
}
|
||||
final boolean memberRequired = !app.has("guarantorMustBeMember") || bool(app, "guarantorMustBeMember");
|
||||
final Long borrower = LoanOriginationJson.lng(app, "clientId");
|
||||
final java.util.Set<Long> seen = new java.util.HashSet<>();
|
||||
for (final JsonElement element : arr(app, "guarantors")) {
|
||||
if (!element.isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject guarantor = element.getAsJsonObject();
|
||||
final Long clientId = LoanOriginationJson.lng(guarantor, "clientId");
|
||||
if (clientId == null) {
|
||||
continue;
|
||||
}
|
||||
require(borrower == null || !borrower.equals(clientId), "The borrower cannot be their own guarantor.");
|
||||
require(seen.add(clientId), "A guarantor has been named twice.");
|
||||
require(!memberRequired || bool(guarantor, "hasShareAccount"),
|
||||
strOr(guarantor, "name", "A guarantor") + " has no active share account: a guarantor must be a bank member.");
|
||||
}
|
||||
require(seen.size() >= need, "This product needs " + need + " guarantor(s); " + seen.size() + " named.");
|
||||
return;
|
||||
}
|
||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
if (bool(app, "guarantorRequired")) {
|
||||
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
|
||||
// onboarded client id AND a live share account.
|
||||
@@ -819,31 +424,21 @@ public class LoanOriginationStageService {
|
||||
}
|
||||
|
||||
private void requireLegalChecklistDone(final JsonObject app) {
|
||||
// A loan against a deposit needs only the stamp duty at this stage (policy 9.5): no fee or file-filing proof.
|
||||
final boolean depositLoan = isDepositLoan(app);
|
||||
require(depositLoan || bool(app, "feesAndAccountOpened"), "The processing fee has not been collected.");
|
||||
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
|
||||
}
|
||||
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
|
||||
require(depositLoan || bool(app, "fileFilingDone"), "File filing is not complete.");
|
||||
require(bool(app, "fileFilingDone"), "File filing is not complete.");
|
||||
for (final JsonElement element : arr(app, "legalDocuments")) {
|
||||
final JsonObject doc = element.getAsJsonObject();
|
||||
if (depositLoan && !bool(doc, "custom")
|
||||
&& ("feeReceipt".equals(str(doc, "key")) || "fileFilingAck".equals(str(doc, "key")))) {
|
||||
continue;
|
||||
}
|
||||
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePostCheckSignatures(final JsonObject app) {
|
||||
require(bool(app, "signedByManager"), "The manager has not signed off.");
|
||||
if (isDepositLoan(app)) {
|
||||
// Committee, risk and legal already approved through their own maker-checker stages.
|
||||
return;
|
||||
}
|
||||
require(bool(app, "signedByAgent"), "The agent has not signed off.");
|
||||
require(bool(app, "signedByManager"), "The manager has not signed off.");
|
||||
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
|
||||
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
|
||||
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
|
||||
|
||||
+2
-3
@@ -679,9 +679,8 @@ public final class LoanProductDataValidator {
|
||||
|
||||
final Long transfersInSuspenseAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), element);
|
||||
// not mapped by the bank (only the nine GLs are); optional
|
||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.TRANSFERS_SUSPENSE.getValue())
|
||||
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
.value(transfersInSuspenseAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final Long incomeFromInterestId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.INTEREST_ON_LOANS.getValue(), element);
|
||||
@@ -701,7 +700,7 @@ public final class LoanProductDataValidator {
|
||||
final Long incomeFromRecoveryAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue(), element);
|
||||
baseDataValidator.reset().parameter(LoanProductAccountingParams.INCOME_FROM_RECOVERY.getValue())
|
||||
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
.value(incomeFromRecoveryAccountId).notNull().integerGreaterThanZero();
|
||||
|
||||
final Long writeOffAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||
|
||||
+2
-332
@@ -9,15 +9,11 @@ package org.apache.fineract.portfolio.loanproduct.service;
|
||||
|
||||
import com.google.gson.JsonElement;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
|
||||
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
|
||||
@@ -47,8 +43,7 @@ public class LoanProductAdvanceExtService {
|
||||
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
||||
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
||||
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "smaStartAfterMonths", "stockStatementValidityDays", "memberRequired", "guarantorMustBeMember",
|
||||
"incomeMultiple", "minVintageMonths", "requiredDocuments", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
||||
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
||||
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
||||
@@ -81,17 +76,7 @@ public class LoanProductAdvanceExtService {
|
||||
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
||||
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
||||
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed",
|
||||
// SICR & LGD (RBI 2026 ECL framework)
|
||||
"sicrDaysThreshold", "sicrRevolvingDaysThreshold", "sicrRatingDowngradeNotches", "sicrPricingIncreaseTriggerPct",
|
||||
"sicrAssessmentBasis", "sicrRebuttalAllowed", "npaOutOfOrderBasis", "cropDuration", "useOwnLgdModel", "lgdSecuredPct",
|
||||
"lgdUnsecuredPct", "eligibleCollateralType",
|
||||
// Loan against Gold
|
||||
"goldMaxLtv", "goldMinPurityCarat", "goldRateSource", "goldMaxPerGram", "goldMinNetWeight", "goldMaxNetWeightPerCustomer", "goldAllowedItems", "goldAppraiserRequired", "goldValuationCertificateRequired", "goldMaxValuationPerAppraiser", "goldPacketPrefix", "goldAuctionNoticeDays", "goldRevalueOnNpa", "goldRevaluationChargeApplicable",
|
||||
"goldPartialReleaseAllowed", "goldThirdPartyAllowed", "goldDisbursementMode", "goldCashDisbursementLimit",
|
||||
"goldLtvBreachAlert", "goldAuctionReservePct", "goldRedeemBeforeAuction",
|
||||
// Loan against Warehouse Receipt
|
||||
"wrMaxLtv", "wrReceiptType", "wrWdraRegistered", "wrAssayCertificateRequired", "wrTenureWithinReceiptValidity", "wrPledgeMarkedBeforeDisbursement", "wrCommodityGroups", "wrValuationPriceSource", "wrMaxLoanPerQuintal", "wrStockInspectionMonths", "wrLtvBreachAlert", "wrPartialReleaseAllowed", "wrDisposalNoticeDays", "wrDisposalReservePct", "wrRedeemBeforeDisposal" };
|
||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
|
||||
|
||||
/**
|
||||
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
||||
@@ -105,205 +90,10 @@ public class LoanProductAdvanceExtService {
|
||||
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
|
||||
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
|
||||
applyScalarFields(ext, command, changes);
|
||||
validateGold(ext, command);
|
||||
validateWarehouse(ext, command);
|
||||
this.repository.save(ext);
|
||||
return changes;
|
||||
}
|
||||
|
||||
/** Longest tenure of a bullet Gold loan, in months. */
|
||||
private static final int GOLD_MAX_TENURE_MONTHS = 12;
|
||||
|
||||
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
|
||||
private static final BigDecimal GOLD_CASH_LIMIT = BigDecimal.valueOf(20000);
|
||||
|
||||
/**
|
||||
* Loan against Gold rules, checked on the merged state (what is stored plus what this command changes), so a
|
||||
* partial edit cannot leave the product inconsistent: the LTV cap must cover the LTV the margin gives, the
|
||||
* purity floor is 18 to 24 carat, and a bullet gold loan runs at most 12 months.
|
||||
*/
|
||||
private void validateGold(final LoanProductAdvanceExt ext, final JsonCommand command) {
|
||||
if (!"gold".equals(ext.getProductTypeSubGroup())) {
|
||||
return;
|
||||
}
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final BigDecimal hundred = BigDecimal.valueOf(100);
|
||||
final BigDecimal cap = ext.getGoldMaxLtvPct();
|
||||
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.maxLtv.invalid",
|
||||
"Maximum LTV cap is required and must be above 0 and at most 100", "goldMaxLtv"));
|
||||
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.margin.ltvAboveCap",
|
||||
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
|
||||
}
|
||||
final Integer purity = ext.getGoldMinPurityCarat();
|
||||
if (purity == null || purity < 18 || purity > 24) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.minPurity.invalid",
|
||||
"Minimum purity must be between 18 and 24 carat", "goldMinPurityCarat"));
|
||||
}
|
||||
if (isBlank(ext.getGoldRateSource())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.rateSource.required",
|
||||
"Valuation rate source is required", "goldRateSource"));
|
||||
}
|
||||
if (isBlank(ext.getGoldAllowedItems())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.allowedItems.required",
|
||||
"Allowed items is required", "goldAllowedItems"));
|
||||
}
|
||||
if (ext.getGoldAuctionNoticeDays() == null || ext.getGoldAuctionNoticeDays() < 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionNotice.invalid",
|
||||
"Auction notice period is required and cannot be negative", "goldAuctionNoticeDays"));
|
||||
}
|
||||
if (ext.getGoldMinNetWeightG() != null && ext.getGoldMaxNetWeightPerCustomerG() != null
|
||||
&& ext.getGoldMinNetWeightG().compareTo(ext.getGoldMaxNetWeightPerCustomerG()) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.netWeight.range",
|
||||
"Minimum net weight cannot exceed the maximum per customer", "goldMinNetWeight"));
|
||||
}
|
||||
final BigDecimal alert = ext.getGoldLtvBreachAlertPct();
|
||||
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.invalid",
|
||||
"LTV breach alert is required and must be above 0 and at most 100", "goldLtvBreachAlert"));
|
||||
} else if (cap != null && alert.compareTo(cap) < 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.ltvAlert.belowCap",
|
||||
"LTV breach alert cannot be below the Maximum LTV cap", "goldLtvBreachAlert"));
|
||||
}
|
||||
final BigDecimal reserve = ext.getGoldAuctionReservePct();
|
||||
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.auctionReserve.invalid",
|
||||
"Auction reserve price is required and must be above 0 and at most 100 percent of market value",
|
||||
"goldAuctionReservePct"));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(ext.getGoldRedeemBeforeAuction())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.redeem.mandatory",
|
||||
"The customer's right to redeem before the auction cannot be switched off", "goldRedeemBeforeAuction"));
|
||||
}
|
||||
final String mode = ext.getGoldDisbursementMode();
|
||||
if (!"account_credit".equals(mode) && !"account_credit_cash_limit".equals(mode)) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.disbursementMode.invalid",
|
||||
"Disbursement mode must be account credit, or account credit with a cash limit",
|
||||
"goldDisbursementMode"));
|
||||
} else if ("account_credit_cash_limit".equals(mode)) {
|
||||
final BigDecimal cashLimit = ext.getGoldCashDisbursementLimit();
|
||||
if (cashLimit == null || cashLimit.signum() < 0 || cashLimit.compareTo(GOLD_CASH_LIMIT) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.cashLimit.invalid",
|
||||
"Cash disbursement limit must be between 0 and 20,000 (Income Tax Sec 269SS)",
|
||||
"goldCashDisbursementLimit"));
|
||||
}
|
||||
}
|
||||
if (command.parameterExists("maxNumberOfRepayments")) {
|
||||
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
|
||||
if (maxTenure != null && maxTenure > GOLD_MAX_TENURE_MONTHS) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.gold.tenure.exceeds",
|
||||
"A gold loan runs at most " + GOLD_MAX_TENURE_MONTHS + " months", "maxNumberOfRepayments"));
|
||||
}
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
/** Longest tenure of a warehouse receipt loan, in months. */
|
||||
private static final int WAREHOUSE_MAX_TENURE_MONTHS = 12;
|
||||
|
||||
private static final java.util.Set<String> WR_RECEIPT_TYPES = java.util.Set.of("e_nwr", "paper_nwr", "both");
|
||||
private static final java.util.Set<String> WR_COMMODITY_GROUPS = java.util.Set.of("cereals", "pulses", "oilseeds", "spices",
|
||||
"others");
|
||||
private static final java.util.Set<String> WR_PRICE_SOURCES = java.util.Set.of("mandi_apmc", "msp", "exchange_ncdex",
|
||||
"bank_assessed");
|
||||
|
||||
/**
|
||||
* Loan against Warehouse Receipt rules, checked on the merged state like the gold rules: the LTV cap must cover the
|
||||
* LTV the margin gives, the alert level must not sit below the cap, the receipt must come from a WDRA-registered
|
||||
* warehouse with an assay certificate, the customer's right to redeem stays on, and the loan runs at most 12 months.
|
||||
*/
|
||||
private void validateWarehouse(final LoanProductAdvanceExt ext, final JsonCommand command) {
|
||||
if (!"warehouse".equals(ext.getProductTypeSubGroup())) {
|
||||
return;
|
||||
}
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
final BigDecimal hundred = BigDecimal.valueOf(100);
|
||||
final BigDecimal cap = ext.getWrMaxLtvPct();
|
||||
if (cap == null || cap.signum() <= 0 || cap.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxLtv.invalid",
|
||||
"Maximum LTV cap is required and must be above 0 and at most 100", "wrMaxLtv"));
|
||||
} else if (ext.getMarginPct() != null && hundred.subtract(ext.getMarginPct()).compareTo(cap) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.margin.ltvAboveCap",
|
||||
"Margin gives an LTV above the Maximum LTV cap; raise the margin or the cap", "margin"));
|
||||
}
|
||||
if (!WR_RECEIPT_TYPES.contains(ext.getWrReceiptType())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.receiptType.invalid",
|
||||
"Receipt type must be e-NWR, paper NWR or both", "wrReceiptType"));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(ext.getWrWdraRegistered())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.wdra.mandatory",
|
||||
"The warehouse must be WDRA-registered; this cannot be switched off", "wrWdraRegistered"));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(ext.getWrAssayCertificateRequired())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.assay.mandatory",
|
||||
"A quality / assay certificate is mandatory; this cannot be switched off", "wrAssayCertificateRequired"));
|
||||
}
|
||||
if (isBlank(ext.getWrCommodityGroups())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.required",
|
||||
"Pick at least one eligible commodity group", "wrCommodityGroups"));
|
||||
} else {
|
||||
for (final String group : ext.getWrCommodityGroups().split("\\|")) {
|
||||
if (!WR_COMMODITY_GROUPS.contains(group)) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.commodityGroups.invalid",
|
||||
"Unknown commodity group: " + group, "wrCommodityGroups"));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!WR_PRICE_SOURCES.contains(ext.getWrValuationPriceSource())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.priceSource.invalid",
|
||||
"Valuation price source is required", "wrValuationPriceSource"));
|
||||
}
|
||||
if (ext.getWrMaxLoanPerQuintal() != null && ext.getWrMaxLoanPerQuintal().signum() <= 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.maxPerQuintal.invalid",
|
||||
"Max loan per quintal must be above 0", "wrMaxLoanPerQuintal"));
|
||||
}
|
||||
if (ext.getWrStockInspectionMonths() == null || ext.getWrStockInspectionMonths() < 1) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.inspection.invalid",
|
||||
"Stock inspection frequency must be at least 1 month", "wrStockInspectionMonths"));
|
||||
}
|
||||
final BigDecimal alert = ext.getWrLtvBreachAlertPct();
|
||||
if (alert == null || alert.signum() <= 0 || alert.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.invalid",
|
||||
"LTV breach alert is required and must be above 0 and at most 100", "wrLtvBreachAlert"));
|
||||
} else if (cap != null && alert.compareTo(cap) < 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.ltvAlert.belowCap",
|
||||
"LTV breach alert cannot be below the Maximum LTV cap", "wrLtvBreachAlert"));
|
||||
}
|
||||
if (ext.getWrDisposalNoticeDays() == null || ext.getWrDisposalNoticeDays() < 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalNotice.invalid",
|
||||
"Disposal notice period is required and cannot be negative", "wrDisposalNoticeDays"));
|
||||
}
|
||||
final BigDecimal reserve = ext.getWrDisposalReservePct();
|
||||
if (reserve == null || reserve.signum() <= 0 || reserve.compareTo(hundred) > 0) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.disposalReserve.invalid",
|
||||
"Disposal reserve price is required and must be above 0 and at most 100 percent of market value",
|
||||
"wrDisposalReservePct"));
|
||||
}
|
||||
if (!Boolean.TRUE.equals(ext.getWrRedeemBeforeDisposal())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.redeem.mandatory",
|
||||
"The customer's right to redeem before the disposal cannot be switched off", "wrRedeemBeforeDisposal"));
|
||||
}
|
||||
if (command.parameterExists("maxNumberOfRepayments")) {
|
||||
final Integer maxTenure = command.integerValueOfParameterNamed("maxNumberOfRepayments");
|
||||
if (maxTenure != null && maxTenure > WAREHOUSE_MAX_TENURE_MONTHS) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg.loanproduct.warehouse.tenure.exceeds",
|
||||
"A warehouse receipt loan runs at most " + WAREHOUSE_MAX_TENURE_MONTHS + " months",
|
||||
"maxNumberOfRepayments"));
|
||||
}
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException(errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isBlank(final String value) {
|
||||
return value == null || value.isBlank();
|
||||
}
|
||||
|
||||
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
|
||||
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
|
||||
.orElseGet(LinkedHashMap::new);
|
||||
@@ -365,14 +155,6 @@ public class LoanProductAdvanceExtService {
|
||||
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
|
||||
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
|
||||
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
|
||||
setInteger(command, "smaStartAfterMonths", ext::getSmaStartAfterMonths, ext::setSmaStartAfterMonths, changes);
|
||||
setBoolean(command, "memberRequired", ext::getMemberRequired, ext::setMemberRequired, changes);
|
||||
setBoolean(command, "guarantorMustBeMember", ext::getGuarantorMustBeMember, ext::setGuarantorMustBeMember, changes);
|
||||
setDecimal(command, "incomeMultiple", ext::getIncomeMultiple, ext::setIncomeMultiple, changes);
|
||||
setInteger(command, "minVintageMonths", ext::getMinVintageMonths, ext::setMinVintageMonths, changes);
|
||||
setString(command, "requiredDocuments", ext::getRequiredDocuments, ext::setRequiredDocuments, changes);
|
||||
setInteger(command, "stockStatementValidityDays", ext::getStockStatementValidityDays,
|
||||
ext::setStockStatementValidityDays, changes);
|
||||
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
|
||||
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
|
||||
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
|
||||
@@ -381,48 +163,6 @@ public class LoanProductAdvanceExtService {
|
||||
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
|
||||
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
|
||||
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
|
||||
setDecimal(command, "goldMaxLtv", ext::getGoldMaxLtvPct, ext::setGoldMaxLtvPct, changes);
|
||||
setInteger(command, "goldMinPurityCarat", ext::getGoldMinPurityCarat, ext::setGoldMinPurityCarat, changes);
|
||||
setString(command, "goldRateSource", ext::getGoldRateSource, ext::setGoldRateSource, changes);
|
||||
setDecimal(command, "goldMaxPerGram", ext::getGoldMaxPerGram, ext::setGoldMaxPerGram, changes);
|
||||
setDecimal(command, "goldMinNetWeight", ext::getGoldMinNetWeightG, ext::setGoldMinNetWeightG, changes);
|
||||
setDecimal(command, "goldMaxNetWeightPerCustomer", ext::getGoldMaxNetWeightPerCustomerG, ext::setGoldMaxNetWeightPerCustomerG, changes);
|
||||
setString(command, "goldAllowedItems", ext::getGoldAllowedItems, ext::setGoldAllowedItems, changes);
|
||||
setBoolean(command, "goldAppraiserRequired", ext::getGoldAppraiserRequired, ext::setGoldAppraiserRequired, changes);
|
||||
setBoolean(command, "goldValuationCertificateRequired", ext::getGoldValuationCertificateRequired, ext::setGoldValuationCertificateRequired, changes);
|
||||
setDecimal(command, "goldMaxValuationPerAppraiser", ext::getGoldMaxValuationPerAppraiser, ext::setGoldMaxValuationPerAppraiser, changes);
|
||||
setString(command, "goldPacketPrefix", ext::getGoldPacketPrefix, ext::setGoldPacketPrefix, changes);
|
||||
setInteger(command, "goldAuctionNoticeDays", ext::getGoldAuctionNoticeDays, ext::setGoldAuctionNoticeDays, changes);
|
||||
setBoolean(command, "goldRevalueOnNpa", ext::getGoldRevalueOnNpa, ext::setGoldRevalueOnNpa, changes);
|
||||
setBoolean(command, "goldRevaluationChargeApplicable", ext::getGoldRevaluationChargeApplicable,
|
||||
ext::setGoldRevaluationChargeApplicable, changes);
|
||||
setBoolean(command, "goldPartialReleaseAllowed", ext::getGoldPartialReleaseAllowed,
|
||||
ext::setGoldPartialReleaseAllowed, changes);
|
||||
setBoolean(command, "goldThirdPartyAllowed", ext::getGoldThirdPartyAllowed, ext::setGoldThirdPartyAllowed,
|
||||
changes);
|
||||
setString(command, "goldDisbursementMode", ext::getGoldDisbursementMode, ext::setGoldDisbursementMode, changes);
|
||||
setDecimal(command, "goldCashDisbursementLimit", ext::getGoldCashDisbursementLimit,
|
||||
ext::setGoldCashDisbursementLimit, changes);
|
||||
setDecimal(command, "goldLtvBreachAlert", ext::getGoldLtvBreachAlertPct, ext::setGoldLtvBreachAlertPct, changes);
|
||||
setDecimal(command, "goldAuctionReservePct", ext::getGoldAuctionReservePct, ext::setGoldAuctionReservePct,
|
||||
changes);
|
||||
setBoolean(command, "goldRedeemBeforeAuction", ext::getGoldRedeemBeforeAuction, ext::setGoldRedeemBeforeAuction,
|
||||
changes);
|
||||
setDecimal(command, "wrMaxLtv", ext::getWrMaxLtvPct, ext::setWrMaxLtvPct, changes);
|
||||
setString(command, "wrReceiptType", ext::getWrReceiptType, ext::setWrReceiptType, changes);
|
||||
setBoolean(command, "wrWdraRegistered", ext::getWrWdraRegistered, ext::setWrWdraRegistered, changes);
|
||||
setBoolean(command, "wrAssayCertificateRequired", ext::getWrAssayCertificateRequired, ext::setWrAssayCertificateRequired, changes);
|
||||
setBoolean(command, "wrTenureWithinReceiptValidity", ext::getWrTenureWithinReceiptValidity, ext::setWrTenureWithinReceiptValidity, changes);
|
||||
setBoolean(command, "wrPledgeMarkedBeforeDisbursement", ext::getWrPledgeMarkedBeforeDisbursement, ext::setWrPledgeMarkedBeforeDisbursement, changes);
|
||||
setString(command, "wrCommodityGroups", ext::getWrCommodityGroups, ext::setWrCommodityGroups, changes);
|
||||
setString(command, "wrValuationPriceSource", ext::getWrValuationPriceSource, ext::setWrValuationPriceSource, changes);
|
||||
setDecimal(command, "wrMaxLoanPerQuintal", ext::getWrMaxLoanPerQuintal, ext::setWrMaxLoanPerQuintal, changes);
|
||||
setInteger(command, "wrStockInspectionMonths", ext::getWrStockInspectionMonths, ext::setWrStockInspectionMonths, changes);
|
||||
setDecimal(command, "wrLtvBreachAlert", ext::getWrLtvBreachAlertPct, ext::setWrLtvBreachAlertPct, changes);
|
||||
setBoolean(command, "wrPartialReleaseAllowed", ext::getWrPartialReleaseAllowed, ext::setWrPartialReleaseAllowed, changes);
|
||||
setInteger(command, "wrDisposalNoticeDays", ext::getWrDisposalNoticeDays, ext::setWrDisposalNoticeDays, changes);
|
||||
setDecimal(command, "wrDisposalReservePct", ext::getWrDisposalReservePct, ext::setWrDisposalReservePct, changes);
|
||||
setBoolean(command, "wrRedeemBeforeDisposal", ext::getWrRedeemBeforeDisposal, ext::setWrRedeemBeforeDisposal, changes);
|
||||
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
|
||||
changes);
|
||||
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
|
||||
@@ -549,20 +289,6 @@ public class LoanProductAdvanceExtService {
|
||||
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
|
||||
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
|
||||
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
|
||||
// SICR & LGD (RBI 2026 ECL framework)
|
||||
setInteger(command, "sicrDaysThreshold", ext::getSicrDaysThreshold, ext::setSicrDaysThreshold, changes);
|
||||
setInteger(command, "sicrRevolvingDaysThreshold", ext::getSicrRevolvingDaysThreshold, ext::setSicrRevolvingDaysThreshold, changes);
|
||||
setInteger(command, "sicrRatingDowngradeNotches", ext::getSicrRatingDowngradeNotches, ext::setSicrRatingDowngradeNotches, changes);
|
||||
setDecimal(command, "sicrPricingIncreaseTriggerPct", ext::getSicrPricingIncreaseTriggerPct, ext::setSicrPricingIncreaseTriggerPct,
|
||||
changes);
|
||||
setString(command, "sicrAssessmentBasis", ext::getSicrAssessmentBasis, ext::setSicrAssessmentBasis, changes);
|
||||
setBoolean(command, "sicrRebuttalAllowed", ext::getSicrRebuttalAllowed, ext::setSicrRebuttalAllowed, changes);
|
||||
setString(command, "npaOutOfOrderBasis", ext::getNpaOutOfOrderBasis, ext::setNpaOutOfOrderBasis, changes);
|
||||
setString(command, "cropDuration", ext::getCropDuration, ext::setCropDuration, changes);
|
||||
setBoolean(command, "useOwnLgdModel", ext::getUseOwnLgdModel, ext::setUseOwnLgdModel, changes);
|
||||
setDecimal(command, "lgdSecuredPct", ext::getLgdSecuredPct, ext::setLgdSecuredPct, changes);
|
||||
setDecimal(command, "lgdUnsecuredPct", ext::getLgdUnsecuredPct, ext::setLgdUnsecuredPct, changes);
|
||||
setString(command, "eligibleCollateralType", ext::getEligibleCollateralType, ext::setEligibleCollateralType, changes);
|
||||
}
|
||||
|
||||
// ---- typed setters: only write params that are present, and only report real changes ----------
|
||||
@@ -677,13 +403,6 @@ public class LoanProductAdvanceExtService {
|
||||
map.put("penalAppliedOn", ext.getPenalAppliedOn());
|
||||
map.put("penalGraceDays", ext.getPenalGraceDays());
|
||||
map.put("npaTrigger", ext.getNpaTrigger());
|
||||
map.put("smaStartAfterMonths", ext.getSmaStartAfterMonths());
|
||||
map.put("memberRequired", ext.getMemberRequired());
|
||||
map.put("guarantorMustBeMember", ext.getGuarantorMustBeMember());
|
||||
map.put("incomeMultiple", ext.getIncomeMultiple());
|
||||
map.put("minVintageMonths", ext.getMinVintageMonths());
|
||||
map.put("requiredDocuments", ext.getRequiredDocuments());
|
||||
map.put("stockStatementValidityDays", ext.getStockStatementValidityDays());
|
||||
map.put("interestAfterNpa", ext.getInterestAfterNpa());
|
||||
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
|
||||
map.put("renewalAllowed", ext.getRenewalAllowed());
|
||||
@@ -692,42 +411,6 @@ public class LoanProductAdvanceExtService {
|
||||
map.put("preclosureAllowed", ext.getPreclosureAllowed());
|
||||
map.put("lockInMonths", ext.getLockInMonths());
|
||||
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
|
||||
map.put("goldMaxLtv", ext.getGoldMaxLtvPct());
|
||||
map.put("goldMinPurityCarat", ext.getGoldMinPurityCarat());
|
||||
map.put("goldRateSource", ext.getGoldRateSource());
|
||||
map.put("goldMaxPerGram", ext.getGoldMaxPerGram());
|
||||
map.put("goldMinNetWeight", ext.getGoldMinNetWeightG());
|
||||
map.put("goldMaxNetWeightPerCustomer", ext.getGoldMaxNetWeightPerCustomerG());
|
||||
map.put("goldAllowedItems", ext.getGoldAllowedItems());
|
||||
map.put("goldAppraiserRequired", ext.getGoldAppraiserRequired());
|
||||
map.put("goldValuationCertificateRequired", ext.getGoldValuationCertificateRequired());
|
||||
map.put("goldMaxValuationPerAppraiser", ext.getGoldMaxValuationPerAppraiser());
|
||||
map.put("goldPacketPrefix", ext.getGoldPacketPrefix());
|
||||
map.put("goldAuctionNoticeDays", ext.getGoldAuctionNoticeDays());
|
||||
map.put("goldRevalueOnNpa", ext.getGoldRevalueOnNpa());
|
||||
map.put("goldRevaluationChargeApplicable", ext.getGoldRevaluationChargeApplicable());
|
||||
map.put("goldPartialReleaseAllowed", ext.getGoldPartialReleaseAllowed());
|
||||
map.put("goldThirdPartyAllowed", ext.getGoldThirdPartyAllowed());
|
||||
map.put("goldDisbursementMode", ext.getGoldDisbursementMode());
|
||||
map.put("goldCashDisbursementLimit", ext.getGoldCashDisbursementLimit());
|
||||
map.put("goldLtvBreachAlert", ext.getGoldLtvBreachAlertPct());
|
||||
map.put("goldAuctionReservePct", ext.getGoldAuctionReservePct());
|
||||
map.put("goldRedeemBeforeAuction", ext.getGoldRedeemBeforeAuction());
|
||||
map.put("wrMaxLtv", ext.getWrMaxLtvPct());
|
||||
map.put("wrReceiptType", ext.getWrReceiptType());
|
||||
map.put("wrWdraRegistered", ext.getWrWdraRegistered());
|
||||
map.put("wrAssayCertificateRequired", ext.getWrAssayCertificateRequired());
|
||||
map.put("wrTenureWithinReceiptValidity", ext.getWrTenureWithinReceiptValidity());
|
||||
map.put("wrPledgeMarkedBeforeDisbursement", ext.getWrPledgeMarkedBeforeDisbursement());
|
||||
map.put("wrCommodityGroups", ext.getWrCommodityGroups());
|
||||
map.put("wrValuationPriceSource", ext.getWrValuationPriceSource());
|
||||
map.put("wrMaxLoanPerQuintal", ext.getWrMaxLoanPerQuintal());
|
||||
map.put("wrStockInspectionMonths", ext.getWrStockInspectionMonths());
|
||||
map.put("wrLtvBreachAlert", ext.getWrLtvBreachAlertPct());
|
||||
map.put("wrPartialReleaseAllowed", ext.getWrPartialReleaseAllowed());
|
||||
map.put("wrDisposalNoticeDays", ext.getWrDisposalNoticeDays());
|
||||
map.put("wrDisposalReservePct", ext.getWrDisposalReservePct());
|
||||
map.put("wrRedeemBeforeDisposal", ext.getWrRedeemBeforeDisposal());
|
||||
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
|
||||
map.put("insuranceRequired", ext.getInsuranceRequired());
|
||||
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
|
||||
@@ -826,19 +509,6 @@ public class LoanProductAdvanceExtService {
|
||||
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
|
||||
map.put("topUpAllowed", ext.getTopUpAllowed());
|
||||
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
|
||||
// SICR & LGD (RBI 2026 ECL framework)
|
||||
map.put("sicrDaysThreshold", ext.getSicrDaysThreshold());
|
||||
map.put("sicrRevolvingDaysThreshold", ext.getSicrRevolvingDaysThreshold());
|
||||
map.put("sicrRatingDowngradeNotches", ext.getSicrRatingDowngradeNotches());
|
||||
map.put("sicrPricingIncreaseTriggerPct", ext.getSicrPricingIncreaseTriggerPct());
|
||||
map.put("sicrAssessmentBasis", ext.getSicrAssessmentBasis());
|
||||
map.put("sicrRebuttalAllowed", ext.getSicrRebuttalAllowed());
|
||||
map.put("npaOutOfOrderBasis", ext.getNpaOutOfOrderBasis());
|
||||
map.put("cropDuration", ext.getCropDuration());
|
||||
map.put("useOwnLgdModel", ext.getUseOwnLgdModel());
|
||||
map.put("lgdSecuredPct", ext.getLgdSecuredPct());
|
||||
map.put("lgdUnsecuredPct", ext.getLgdUnsecuredPct());
|
||||
map.put("eligibleCollateralType", ext.getEligibleCollateralType());
|
||||
return map;
|
||||
}
|
||||
}
|
||||
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.service.PrudentialFloorReadPlatformService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Read-only: this master is seeded from the RBI 2026 Directions (para 82) rather than created per bank, so it
|
||||
* ships without a create/update command path for now - a Board-approved "tighten only" override is a follow-up.
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Path("/v1/prudentialfloors")
|
||||
@Component
|
||||
@Tag(name = "Prudential Floor Master", description = "RBI 2026 ECL stage-wise prudential floor lookup, by loan product category")
|
||||
public class PrudentialFloorsApiResource {
|
||||
|
||||
private final PlatformSecurityContext securityContext;
|
||||
private final PrudentialFloorReadPlatformService readPlatformService;
|
||||
public static final String PRUDENTIAL_FLOOR = "PRUDENTIAL_FLOOR";
|
||||
|
||||
@GET
|
||||
@Consumes({ MediaType.TEXT_HTML, MediaType.APPLICATION_JSON })
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "List all Prudential Floor rows", description = "Optionally filter by product category")
|
||||
public List<PrudentialFloorData> getPrudentialFloors(
|
||||
@QueryParam("productCategory") @Parameter(description = "productCategory") final String productCategory) {
|
||||
securityContext.authenticatedUser().validateHasReadPermission(PRUDENTIAL_FLOOR);
|
||||
if (productCategory == null || productCategory.isBlank()) {
|
||||
return this.readPlatformService.retrieveAll();
|
||||
}
|
||||
return this.readPlatformService.retrieveForCategory(productCategory);
|
||||
}
|
||||
}
|
||||
-63
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* 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.prudentialfloor.service;
|
||||
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.data.PrudentialFloorData;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloor;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.domain.PrudentialFloorRepository;
|
||||
import org.apache.fineract.portfolio.prudentialfloor.mapper.PrudentialFloorMapper;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Read-only lookup over the RBI 2026 prudential-floor master (para 82). A loan product never stores its own
|
||||
* floor %: it declares a product category and a stage (the latter via its Delinquency Bucket), and the
|
||||
* applicable floor is resolved here at reporting/provisioning time, so every product using the same category
|
||||
* always sees the same, currently-seeded regulatory minimum.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@Transactional(readOnly = true)
|
||||
public class PrudentialFloorReadPlatformService {
|
||||
|
||||
private final PrudentialFloorRepository repository;
|
||||
private final PrudentialFloorMapper mapper;
|
||||
|
||||
public List<PrudentialFloorData> retrieveAll() {
|
||||
return mapper.map(repository.findAll());
|
||||
}
|
||||
|
||||
public List<PrudentialFloorData> retrieveForCategory(final String productCategory) {
|
||||
return mapper.map(repository.findByProductCategoryOrderByStageAscDurationFromDaysAsc(productCategory));
|
||||
}
|
||||
|
||||
/**
|
||||
* The floor applicable to one exposure: for Stage 1/2 the single row for that category+stage; for Stage 3
|
||||
* the row whose duration band contains {@code daysInStage} (days since the exposure entered Stage 3, not
|
||||
* days overdue). Returns {@code null} when the category/stage/duration combination has not been seeded -
|
||||
* callers should fall back to the residual "any other loan product category" floor (para 82 item (xv)) in
|
||||
* that case rather than treat it as zero.
|
||||
*/
|
||||
public PrudentialFloorData retrieveFloor(final String productCategory, final Integer stage, final Integer daysInStage) {
|
||||
final List<PrudentialFloor> candidates = repository.findByProductCategoryAndStage(productCategory, stage);
|
||||
return candidates.stream().filter(row -> row.coversDuration(daysInStage)).findFirst().map(mapper::map).orElse(null);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -148,14 +148,16 @@ public class CurrentAccountsApiResource {
|
||||
@QueryParam("status") @Parameter(description = "status") final String status,
|
||||
@QueryParam("fromDate") @Parameter(description = "fromDate (YYYY-MM-DD)") final String fromDate,
|
||||
@QueryParam("toDate") @Parameter(description = "toDate (YYYY-MM-DD)") final String toDate,
|
||||
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate) {
|
||||
@QueryParam("asOnDate") @Parameter(description = "asOnDate (YYYY-MM-DD)") final String asOnDate,
|
||||
@QueryParam("overdraftOnly") @Parameter(description = "only Cash Credit (overdraft) accounts") final Boolean overdraftOnly) {
|
||||
|
||||
context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.CURRENT_ACCOUNT_RESOURCE_NAME);
|
||||
final List<Map<String, Object>> accounts = currentAccountReadPlatformService.retrieveAll(offset, limit, accountNo, clientName,
|
||||
status,
|
||||
fromDate != null ? LocalDate.parse(fromDate) : null,
|
||||
toDate != null ? LocalDate.parse(toDate) : null,
|
||||
asOnDate != null ? LocalDate.parse(asOnDate) : null);
|
||||
asOnDate != null ? LocalDate.parse(asOnDate) : null,
|
||||
Boolean.TRUE.equals(overdraftOnly));
|
||||
return GSON.toJson(accounts);
|
||||
}
|
||||
|
||||
|
||||
-33
@@ -109,7 +109,6 @@ public class FixedDepositAccountsApiResource {
|
||||
private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService;
|
||||
private final SqlValidator sqlValidator;
|
||||
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
|
||||
private final org.apache.fineract.portfolio.savings.service.FixedDepositAccountPreviewService fixedDepositAccountPreviewService;
|
||||
|
||||
@GET
|
||||
@Path("template")
|
||||
@@ -316,38 +315,6 @@ public class FixedDepositAccountsApiResource {
|
||||
return toApiJsonSerializer.serializeResult(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed deposit quote while the account is being opened, for every FD type: takes the same body as Submit
|
||||
* (clientId, productId, submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, isSeniorCitizen,
|
||||
* locale, dateFormat ...) and returns the rate, maturity date and maturity amount Submit would store, plus the
|
||||
* interest periods (FD Special: the monthly payout schedule). Nothing is saved (see FixedDepositAccountPreviewService).
|
||||
*/
|
||||
@POST
|
||||
@Path("account-preview")
|
||||
@Consumes({ MediaType.APPLICATION_JSON })
|
||||
@Produces({ MediaType.APPLICATION_JSON })
|
||||
public String accountPreview(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson);
|
||||
// Submit insists on a linked savings account - "linkAccountId" (FD Special's monthly payout account) and
|
||||
// "linkedAccountId" (when the product's Deposit Account Link is on) - which the screen asks for after the quote.
|
||||
// The quote does not depend on which account it is, only that the interest goes out monthly, so a stand-in id is
|
||||
// used for whichever is not chosen yet. Both are only recorded on the account, never looked up, and the would-be
|
||||
// account is never saved, so the stand-in goes nowhere.
|
||||
if (parsed.isJsonObject()) {
|
||||
for (final String linkParam : new String[] { DepositsApiConstants.linkedAccountParamName,
|
||||
org.apache.fineract.portfolio.savings.SavingsApiConstants.linkedAccountIdParamName }) {
|
||||
if (!parsed.getAsJsonObject().has(linkParam) || parsed.getAsJsonObject().get(linkParam).isJsonNull()) {
|
||||
parsed.getAsJsonObject().addProperty(linkParam, -1L);
|
||||
}
|
||||
}
|
||||
}
|
||||
final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand
|
||||
.from(parsed.toString(), parsed, this.fromJsonHelper, DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME, null,
|
||||
null, null, null, null, null, null, null, null, null, null, null, null);
|
||||
return this.toApiJsonSerializer.serializeResult(this.fixedDepositAccountPreviewService.preview(command));
|
||||
}
|
||||
|
||||
private BigDecimal getActivationCharge(Long accountId) {
|
||||
BigDecimal activationCharge = BigDecimal.ZERO;
|
||||
Collection<SavingsAccountChargeData> savingCharges = this.savingsAccountChargeReadPlatformService
|
||||
|
||||
-1
@@ -49,6 +49,5 @@ public class RdInterestRateSaveRequest {
|
||||
private Integer fromPeriod;
|
||||
private Integer toPeriod;
|
||||
private BigDecimal annualInterestRate;
|
||||
private BigDecimal seniorCitizenRate;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-4
@@ -23,7 +23,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -72,7 +71,6 @@ public class RecurringDepositDashboardApiResource {
|
||||
JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_office o ON o.id = c.office_id
|
||||
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
|
||||
AND (CAST(? AS BIGINT) IS NULL OR sa.id = CAST(? AS BIGINT))
|
||||
),
|
||||
paid AS (
|
||||
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
|
||||
@@ -180,7 +178,7 @@ public class RecurringDepositDashboardApiResource {
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String retrieve(@QueryParam("accountId") final Long accountId) {
|
||||
public String retrieve() {
|
||||
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
|
||||
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
|
||||
|
||||
@@ -189,7 +187,7 @@ public class RecurringDepositDashboardApiResource {
|
||||
final LocalDate monthStart = today.withDayOfMonth(1);
|
||||
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
|
||||
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy, accountId, accountId,
|
||||
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
|
||||
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
|
||||
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
|
||||
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
|
||||
|
||||
+2
-5
@@ -223,13 +223,10 @@ public class SavingsPooledInterestPostingService {
|
||||
final long runId = openRun(reportId, productId, officeId, cycleDate, lines.size());
|
||||
|
||||
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||
// Long.valueOf, not the bare long: a ternary mixing a long with a Long is a long, so a GL the product does not name
|
||||
// (null, and allowed) was unboxed here and the whole report failed with a NullPointerException.
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null
|
||||
? Long.valueOf(((Number) report.get("payable_gl_account_id")).longValue())
|
||||
? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||
: current.payableGl();
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null
|
||||
? Long.valueOf(((Number) report.get("paid_gl_account_id")).longValue())
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||
: current.paidGl();
|
||||
|
||||
final String currency = (String) lines.get(0).get("currency_code");
|
||||
|
||||
-119
@@ -1,119 +0,0 @@
|
||||
/**
|
||||
* 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.savings.data;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
|
||||
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
|
||||
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
|
||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* FD Special is a non-cumulative product: its interest is paid out to the linked savings account every month, so the
|
||||
* principal never grows and nothing compounds on it - in effect simple interest on the principal. Its product terms are
|
||||
* therefore fixed, and the product screen locks them:
|
||||
* <ul>
|
||||
* <li>Interest Calculation Type ({@code interestCalculateSelector}) = Simple</li>
|
||||
* <li>Interest Compounding Period = Monthly</li>
|
||||
* <li>Interest Posting Period = Monthly</li>
|
||||
* </ul>
|
||||
* Checked here, on the server, against the product as it will be saved, so a request from Postman or any other client
|
||||
* that sends different values is refused with a validation error rather than saved. Used on create, on update (which
|
||||
* the maker-checker approval also goes through) and when a maker submits an edit for approval.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class FdSpecialProductTermsValidator {
|
||||
|
||||
private static final String RESOURCE = "fixeddepositproduct";
|
||||
private static final String SIMPLE = "Simple";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
/** The product as assembled or updated, just before it is saved. */
|
||||
public void validate(final FixedDepositProduct product) {
|
||||
validate(product.getFdType(), product.getInterestCalculateSelector(), valueOf(product.interestCompoundingPeriodType()),
|
||||
valueOf(product.interestPostingPeriodType()));
|
||||
}
|
||||
|
||||
/**
|
||||
* A maker's edit, before it waits for approval: each field as the edit leaves it - the submitted value where the
|
||||
* request sends one, otherwise the product's current value.
|
||||
*/
|
||||
public void validateEdit(final FixedDepositProduct product, final JsonCommand command) {
|
||||
final String fdType = command.parameterExists("fdType") ? command.stringValueOfParameterNamed("fdType") : product.getFdType();
|
||||
final Integer selector = command.parameterExists("interestCalculateSelector")
|
||||
? command.integerValueOfParameterNamed("interestCalculateSelector")
|
||||
: product.getInterestCalculateSelector();
|
||||
final Integer compounding = command.parameterExists("interestCompoundingPeriodType")
|
||||
? command.integerValueOfParameterNamed("interestCompoundingPeriodType")
|
||||
: valueOf(product.interestCompoundingPeriodType());
|
||||
final Integer posting = command.parameterExists("interestPostingPeriodType")
|
||||
? command.integerValueOfParameterNamed("interestPostingPeriodType")
|
||||
: valueOf(product.interestPostingPeriodType());
|
||||
validate(fdType, selector, compounding, posting);
|
||||
}
|
||||
|
||||
private void validate(final String fdType, final Integer interestCalculateSelector, final Integer compoundingPeriodType,
|
||||
final Integer postingPeriodType) {
|
||||
if (!"SPECIAL".equalsIgnoreCase(fdType)) {
|
||||
return;
|
||||
}
|
||||
final List<ApiParameterError> errors = new ArrayList<>();
|
||||
|
||||
final String calculationType = interestCalculateSelector == null ? null
|
||||
: this.jdbcTemplate.queryForList("SELECT code_value FROM m_code_value WHERE id = ?", String.class, interestCalculateSelector)
|
||||
.stream().findFirst().orElse(null);
|
||||
if (calculationType == null || !SIMPLE.equalsIgnoreCase(calculationType.trim())) {
|
||||
errors.add(ApiParameterError.parameterError("validation.msg." + RESOURCE + ".special.interestCalculateSelector.must.be.simple",
|
||||
"FD Special pays its interest out every month, so nothing compounds: Interest Calculation Type must be Simple"
|
||||
+ (calculationType == null ? "." : ", not " + calculationType + "."),
|
||||
"interestCalculateSelector", interestCalculateSelector));
|
||||
}
|
||||
if (compoundingPeriodType == null || compoundingPeriodType != SavingsCompoundingInterestPeriodType.MONTHLY.getValue()) {
|
||||
errors.add(ApiParameterError.parameterError(
|
||||
"validation.msg." + RESOURCE + ".special.interestCompoundingPeriodType.must.be.monthly",
|
||||
"FD Special pays its interest out every month: Interest Compounding Period must be Monthly.",
|
||||
"interestCompoundingPeriodType", compoundingPeriodType));
|
||||
}
|
||||
if (postingPeriodType == null || postingPeriodType != SavingsPostingInterestPeriodType.MONTHLY.getValue()) {
|
||||
errors.add(ApiParameterError.parameterError(
|
||||
"validation.msg." + RESOURCE + ".special.interestPostingPeriodType.must.be.monthly",
|
||||
"FD Special pays its interest out every month: Interest Posting Period must be Monthly.", "interestPostingPeriodType",
|
||||
postingPeriodType));
|
||||
}
|
||||
if (!errors.isEmpty()) {
|
||||
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
|
||||
}
|
||||
}
|
||||
|
||||
private static Integer valueOf(final SavingsCompoundingInterestPeriodType type) {
|
||||
return type == null ? null : type.getValue();
|
||||
}
|
||||
|
||||
private static Integer valueOf(final SavingsPostingInterestPeriodType type) {
|
||||
return type == null ? null : type.getValue();
|
||||
}
|
||||
}
|
||||
-1
@@ -27,5 +27,4 @@ public class RdInterestRateSlabData {
|
||||
private Integer fromPeriod;
|
||||
private Integer toPeriod;
|
||||
private BigDecimal annualInterestRate;
|
||||
private BigDecimal seniorCitizenRate;
|
||||
}
|
||||
|
||||
+1
-7
@@ -52,13 +52,8 @@ public class RdBaseInterestRateSlabHistory {
|
||||
@Column(name = "annual_interest_rate")
|
||||
private BigDecimal annualInterestRate;
|
||||
|
||||
/** The rate a senior citizen earns on this slab; zero means the slab carries no senior premium. */
|
||||
@Column(name = "senior_citizen_rate")
|
||||
private BigDecimal seniorCitizenRate;
|
||||
|
||||
public static RdBaseInterestRateSlabHistory create(Long effectiveId, Integer srNo,
|
||||
String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate,
|
||||
BigDecimal seniorCitizenRate) {
|
||||
String description, Integer periodTypeEnum, Integer fromPeriod, Integer toPeriod, BigDecimal annualInterestRate) {
|
||||
RdBaseInterestRateSlabHistory s = new RdBaseInterestRateSlabHistory();
|
||||
s.effectiveId = effectiveId;
|
||||
s.srNo = srNo;
|
||||
@@ -67,7 +62,6 @@ public class RdBaseInterestRateSlabHistory {
|
||||
s.fromPeriod = fromPeriod;
|
||||
s.toPeriod = toPeriod;
|
||||
s.annualInterestRate = annualInterestRate;
|
||||
s.seniorCitizenRate = seniorCitizenRate != null ? seniorCitizenRate : BigDecimal.ZERO;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
|
||||
+2
-6
@@ -210,13 +210,9 @@ public class FixedDepositInterestPostingService {
|
||||
final Long expenseGl = mappedGlAccount(productId, GL_INTEREST_ON_SAVINGS);
|
||||
final Long controlGl = mappedGlAccount(productId, GL_SAVINGS_CONTROL);
|
||||
final PostingGlAccounts current = resolvePostingGlAccounts(productId);
|
||||
// Long.valueOf, not the bare long: a ternary mixing a long with a Long is a long, so a product that names no payable
|
||||
// or paid GL (null, and allowed) was unboxed here and the whole report failed with a NullPointerException.
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null
|
||||
? Long.valueOf(((Number) report.get("payable_gl_account_id")).longValue())
|
||||
final Long payableGl = report.get("payable_gl_account_id") != null ? ((Number) report.get("payable_gl_account_id")).longValue()
|
||||
: current.payableGl();
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null
|
||||
? Long.valueOf(((Number) report.get("paid_gl_account_id")).longValue())
|
||||
final Long paidGl = report.get("paid_gl_account_id") != null ? ((Number) report.get("paid_gl_account_id")).longValue()
|
||||
: current.paidGl();
|
||||
|
||||
final String currency = (String) lines.get(0).get("currency_code");
|
||||
|
||||
+12
@@ -446,6 +446,13 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||
final LocalDate asOnDate) {
|
||||
return retrieveAll(offset, limit, accountNo, clientName, status, fromDate, toDate, asOnDate, false);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> retrieveAll(final Integer offset, final Integer limit, final String accountNo,
|
||||
final String clientName, final String status, final LocalDate fromDate, final LocalDate toDate,
|
||||
final LocalDate asOnDate, final boolean overdraftOnly) {
|
||||
final StringBuilder sqlBuilder = new StringBuilder(BASE_SQL);
|
||||
final List<Object> params = new ArrayList<>();
|
||||
|
||||
@@ -457,6 +464,11 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
|
||||
params.add(this.context.officeHierarchy() + "%");
|
||||
boolean hasWhere = true;
|
||||
|
||||
// A Cash Credit account is a current account that is allowed an overdraft.
|
||||
if (overdraftOnly) {
|
||||
sqlBuilder.append(" AND ca.allow_overdraft = true");
|
||||
}
|
||||
|
||||
if (StringUtils.isNotBlank(accountNo)) {
|
||||
sqlBuilder.append(hasWhere ? " AND " : " WHERE ");
|
||||
sqlBuilder.append("ca.account_no LIKE ?");
|
||||
|
||||
-148
@@ -1,148 +0,0 @@
|
||||
/**
|
||||
* 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.savings.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.MathContext;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
|
||||
import org.apache.fineract.infrastructure.core.api.JsonCommand;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
|
||||
import org.apache.fineract.portfolio.savings.DepositAccountType;
|
||||
import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
|
||||
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
|
||||
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
|
||||
/**
|
||||
* Fixed deposit quote for the account-opening screen - every FD type - worked out by the account engine itself rather
|
||||
* than by the screen or a separate calculator.
|
||||
* <p>
|
||||
* The would-be account is built exactly as Submit builds it ({@code submitFDApplication}): the same assembler (product
|
||||
* defaults, the product's interest rate chart, the CBS base-rate card and senior-citizen rate), the same maturity
|
||||
* calculation, the same CBS fields. Nothing is saved: it all runs in a transaction that is always rolled back. So the
|
||||
* rate, maturity date and maturity amount quoted are exactly what Submit would store - including the product's posting
|
||||
* periods (e.g. Quarterly posting closes each period at the calendar quarter end, from the financial-year start month),
|
||||
* which a stand-alone formula that counts quarters from the deposit date gets wrong.
|
||||
* <p>
|
||||
* The interest periods come from {@link FixedDepositAccount#projectInterestSchedule}, the method the account's Monthly
|
||||
* Interest Payout Schedule uses once it is open. For FD Special they are the monthly payouts; for a cumulative FD they
|
||||
* are the periods interest is credited to the deposit and compounds.
|
||||
* <p>
|
||||
* The schedule assumes the deposit starts on the submitted date; once opened, the account's own schedule runs from its
|
||||
* actual activation date.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class FixedDepositAccountPreviewService {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final DepositAccountAssembler depositAccountAssembler;
|
||||
private final SavingsAccountAssembler savingsAccountAssembler;
|
||||
private final ConfigurationDomainService configurationDomainService;
|
||||
private final PlatformTransactionManager transactionManager;
|
||||
|
||||
public Map<String, Object> preview(final JsonCommand command) {
|
||||
final TransactionTemplate template = new TransactionTemplate(this.transactionManager);
|
||||
return template.execute(status -> {
|
||||
// Never keep anything the assembler touched: this is a quote, not an application.
|
||||
status.setRollbackOnly();
|
||||
return buildPreview(command);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> buildPreview(final JsonCommand command) {
|
||||
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
|
||||
.isSavingsInterestPostingAtCurrentPeriodEnd();
|
||||
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
|
||||
|
||||
// Same steps, in the same order, as DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl#submitFDApplication.
|
||||
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command,
|
||||
this.context.authenticatedUser(), DepositAccountType.FIXED_DEPOSIT);
|
||||
final boolean special = "SPECIAL".equalsIgnoreCase(account.fdType());
|
||||
account.updateMaturityDateAndAmountBeforeAccountActivation(MathContext.DECIMAL64, false,
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account);
|
||||
|
||||
// Same MathContext and clipping as DepositAccountWritePlatformServiceJpaRepositoryImpl#retrieveProjectedInterestSchedule.
|
||||
final RoundingMode rounding = MoneyHelper.getRoundingMode();
|
||||
final List<PostingPeriod> postingPeriods = account.projectInterestSchedule(new MathContext(15, rounding),
|
||||
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
|
||||
final LocalDate maturityDate = account.maturityDate();
|
||||
final LocalDate lastCalculableDate = maturityDate != null ? maturityDate.minusDays(1) : null;
|
||||
|
||||
final List<Map<String, Object>> periods = new ArrayList<>();
|
||||
BigDecimal totalInterest = BigDecimal.ZERO;
|
||||
int number = 1;
|
||||
for (final PostingPeriod period : postingPeriods) {
|
||||
final LocalDate fromDate = period.getPeriodInterval().startDate();
|
||||
LocalDate toDate = period.getPeriodInterval().endDate();
|
||||
if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) {
|
||||
toDate = lastCalculableDate;
|
||||
}
|
||||
final BigDecimal interest = period.getInterestEarned().getAmount();
|
||||
totalInterest = totalInterest.add(interest);
|
||||
final Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("period", number++);
|
||||
row.put("fromDate", fromDate.toString());
|
||||
row.put("toDate", toDate.toString());
|
||||
row.put("days", ChronoUnit.DAYS.between(fromDate, toDate) + 1);
|
||||
row.put("interestAmount", interest);
|
||||
periods.add(row);
|
||||
}
|
||||
|
||||
final BigDecimal depositAmount = account.getDepositAmount();
|
||||
final BigDecimal rate = account.getNominalAnnualInterestRate();
|
||||
final Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("fdType", account.fdType());
|
||||
result.put("depositAmount", depositAmount);
|
||||
result.put("annualInterestRate", rate);
|
||||
result.put("seniorCitizen", account.isSeniorCitizen());
|
||||
result.put("interestPostingPeriodType", account.getInterestPostingPeriodType());
|
||||
result.put("interestCalculationDaysInYearType", account.getInterestCalculationDaysInYearType());
|
||||
result.put("startDate", periods.isEmpty() ? null : periods.get(0).get("fromDate"));
|
||||
result.put("maturityDate", maturityDate == null ? null : maturityDate.toString());
|
||||
result.put("numberOfPayouts", special ? periods.size() : 0);
|
||||
// FD Special: one full month at the applied rate; a period pays for the days it actually covers (see periods).
|
||||
result.put("fullMonthPayout", !special || rate == null ? null
|
||||
: depositAmount.multiply(rate, MoneyHelper.getMathContext()).divide(BigDecimal.valueOf(1200), MoneyHelper.getMathContext())
|
||||
.setScale(2, rounding));
|
||||
// The account's own maturity amount, as Submit stores it: for FD Special the principal alone (the interest is paid
|
||||
// out month by month), otherwise principal plus the compounded interest.
|
||||
final BigDecimal maturityAmount = account.maturityAmount();
|
||||
final BigDecimal interest = special ? totalInterest
|
||||
: (maturityAmount == null ? null : maturityAmount.subtract(depositAmount));
|
||||
result.put("totalInterest", interest);
|
||||
result.put("maturityAmount", maturityAmount);
|
||||
result.put("totalValueAtMaturity", interest == null ? null : depositAmount.add(interest));
|
||||
result.put("periods", periods);
|
||||
return result;
|
||||
}
|
||||
}
|
||||
-7
@@ -89,7 +89,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository;
|
||||
private final FromJsonHelper fromApiJsonHelper;
|
||||
private final CommandSourceRepository commandSourceRepository;
|
||||
private final org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator;
|
||||
|
||||
@Transactional
|
||||
@Override
|
||||
@@ -99,8 +98,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json());
|
||||
|
||||
final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command);
|
||||
// FD Special: Simple interest, monthly compounding and posting - refused here whatever client sent it.
|
||||
this.fdSpecialProductTermsValidator.validate(product);
|
||||
|
||||
this.fixedDepositProductRepository.saveAndFlush(product);
|
||||
|
||||
@@ -139,8 +136,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
product.setHelpers(this.chartAssembler);
|
||||
|
||||
final Map<String, Object> changes = product.update(command);
|
||||
// The product as this update leaves it (also the path an approved maker-checker edit takes).
|
||||
this.fdSpecialProductTermsValidator.validate(product);
|
||||
|
||||
if (changes.containsKey(chargesParamName)) {
|
||||
final Set<Charge> savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command,
|
||||
@@ -202,8 +197,6 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
|
||||
}
|
||||
|
||||
this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json());
|
||||
// Refused before it waits for a checker, not only when the checker approves it.
|
||||
this.fdSpecialProductTermsValidator.validateEdit(product, command);
|
||||
|
||||
final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker);
|
||||
|
||||
|
||||
+6
-12
@@ -77,7 +77,7 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
||||
final int sr = slabRequest.getSrNo() != null ? slabRequest.getSrNo() : srCounter.getAndIncrement();
|
||||
final RdBaseInterestRateSlabHistory slab = RdBaseInterestRateSlabHistory.create(versionId, sr, slabRequest.getDescription(),
|
||||
slabRequest.getPeriodTypeEnum(), slabRequest.getFromPeriod(), slabRequest.getToPeriod(),
|
||||
slabRequest.getAnnualInterestRate(), slabRequest.getSeniorCitizenRate());
|
||||
slabRequest.getAnnualInterestRate());
|
||||
slabRepository.save(slab);
|
||||
}
|
||||
|
||||
@@ -111,16 +111,13 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
||||
.thenComparing(slab -> slab.getFromPeriod() == null ? Integer.MAX_VALUE : slab.getFromPeriod()));
|
||||
|
||||
final List<RdInterestRateSaveRequest.SlabRequest> normalized = new ArrayList<>(sorted.size());
|
||||
// A chart in days may open at 0; one in months or years opens at 1. Either way each slab then follows on from the last.
|
||||
Integer expectedFrom = null;
|
||||
Integer expectedFrom = 0;
|
||||
for (int i = 0; i < sorted.size(); i++) {
|
||||
final RdInterestRateSaveRequest.SlabRequest source = sorted.get(i);
|
||||
final RdInterestRateSaveRequest.SlabRequest target = new RdInterestRateSaveRequest.SlabRequest();
|
||||
|
||||
final Integer fromPeriod = source.getFromPeriod();
|
||||
final boolean startsRight = fromPeriod != null
|
||||
&& (i == 0 ? (fromPeriod == 0 || fromPeriod == 1) : fromPeriod.equals(expectedFrom));
|
||||
if (!startsRight) {
|
||||
if (fromPeriod == null || !fromPeriod.equals(expectedFrom)) {
|
||||
throwValidationError("fromPeriod", "must.start.from.previous.range.end");
|
||||
}
|
||||
target.setFromPeriod(fromPeriod);
|
||||
@@ -150,15 +147,13 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
||||
if (target.getAnnualInterestRate().signum() < 0) {
|
||||
throwValidationError("annualInterestRate", "must.be.non.negative");
|
||||
}
|
||||
// The senior-citizen rate is optional: left blank it means this slab carries no premium.
|
||||
target.setSeniorCitizenRate(source.getSeniorCitizenRate() == null ? java.math.BigDecimal.ZERO : source.getSeniorCitizenRate());
|
||||
if (target.getSeniorCitizenRate().signum() < 0) {
|
||||
throwValidationError("seniorCitizenRate", "must.be.non.negative");
|
||||
}
|
||||
|
||||
normalized.add(target);
|
||||
}
|
||||
|
||||
if (!normalized.isEmpty() && normalized.get(0).getFromPeriod() != 0) {
|
||||
throwValidationError("fromPeriod", "must.start.from.zero");
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -186,7 +181,6 @@ public class RdBaseInterestRateReadPlatformServiceImpl implements RdBaseInterest
|
||||
.fromPeriod(s.getFromPeriod())
|
||||
.toPeriod(s.getToPeriod())
|
||||
.annualInterestRate(s.getAnnualInterestRate())
|
||||
.seniorCitizenRate(s.getSeniorCitizenRate())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
|
||||
+2
-3
@@ -367,12 +367,11 @@ public class SavingsConfiguration {
|
||||
ProductPendingEditChargeRepository pendingEditChargeRepository,
|
||||
ProductPendingEditGlMappingRepository pendingEditGlMappingRepository,
|
||||
FromJsonHelper fromApiJsonHelper,
|
||||
CommandSourceRepository commandSourceRepository,
|
||||
org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator) {
|
||||
CommandSourceRepository commandSourceRepository) {
|
||||
return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository,
|
||||
fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler,
|
||||
productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository,
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, fdSpecialProductTermsValidator);
|
||||
pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -623,19 +623,6 @@
|
||||
<include file="parts/0595_seed_loan_rate_register_gl_accounts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0596_enable_delinquency_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0597_add_loan_product_loans_ext_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0598_add_loan_product_sma_start_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0599_enable_loan_product_update_maker_checker.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
<!-- RBI 2026 ECL framework: SMA/NPA asset classification, prudential floor master, SICR/LGD product config. -->
|
||||
<include file="parts/0599_add_delinquency_range_stage.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0600_create_prudential_floor_master.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0601_add_loan_product_advance_ext_sicr_lgd_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0604_add_gold_loan_product_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0605_add_gold_revaluation_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0606_add_gold_servicing_and_auction_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0607_add_warehouse_receipt_product_fields.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_seed_priority_sector_category_code.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0609_seed_weaker_section_type_code.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
<!-- DDS: the scheme's own tables, settings and permissions. Kept together and last, because
|
||||
each one builds on the core the block above sets up and none of it builds on DDS. -->
|
||||
|
||||
@@ -178,6 +178,8 @@
|
||||
<include file="parts/0601_voucher_correction.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0602_voucher_drop_status_columns.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0603_cash_transfer_request.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0604_cic_credit_bureau_module.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0605_cic_enquiry_alerts.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0599_current_account_notes.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0600_create_profit_appropriation_config.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0601_create_profit_appropriation_posting.xml" relativeToChangelogFile="true"/>
|
||||
@@ -185,8 +187,7 @@
|
||||
<include file="parts/0603_savings_account_branch_and_client_branch_link.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0604_add_kyc_reminders_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0605_create_kyc_notice_queue.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0606_rd_base_chart_senior_rate_and_months.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0608_risk_rule_trial.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- SMA counting start (Advances: 9 months after disbursement, Loans/EMI: 1 month) and the CC/OD stock
|
||||
statement validity window that feeds the NPA trigger. -->
|
||||
<changeSet author="cbsnexora" id="0598_add_loan_product_sma_start_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="sma_start_after_months" type="INT"/>
|
||||
<column name="stock_statement_validity_days" type="INT"/>
|
||||
<column name="elig_member_required" type="BOOLEAN"/>
|
||||
<column name="elig_guarantor_must_be_member" type="BOOLEAN"/>
|
||||
<column name="elig_income_multiple" type="DECIMAL(10,2)"/>
|
||||
<column name="elig_min_vintage_months" type="INT"/>
|
||||
<column name="required_documents" type="VARCHAR(1000)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!--
|
||||
Maker-checker on editing a loan or advance product.
|
||||
|
||||
Creating a product already goes to a checker (CREATE_LOANPRODUCT is gated). An edit changes rates,
|
||||
limits, margin, GL accounts and lien rules that live accounts depend on, so it must not take effect on
|
||||
one person's click either. UPDATE_LOANPRODUCT is gated with can_maker_checker = true: an edit lands in
|
||||
m_portfolio_command_source as PENDING and is applied when a checker approves it. UPDATE_LOANPRODUCT_CHECKER
|
||||
already exists and is what approving requires. Roles are left as they are.
|
||||
-->
|
||||
|
||||
<changeSet id="0599_loan_product_update_maker_checker" author="cbsnexora" runOnChange="false">
|
||||
<validCheckSum>ANY</validCheckSum>
|
||||
<sql>
|
||||
UPDATE m_permission
|
||||
SET can_maker_checker = TRUE
|
||||
WHERE code = 'UPDATE_LOANPRODUCT';
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-167
@@ -1,167 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- RBI 2026 ECL framework (Directions para 82): the stage-wise prudential floor, keyed by loan product
|
||||
category rather than by loan product. Seeded below with a representative set of categories; the
|
||||
remaining categories and the category-specific Stage 3 table variants (unsecured retail, housing/other
|
||||
real estate, government-guaranteed) are a follow-up seed, not a schema change. created_by/
|
||||
last_modified_by use 1 (the system user) since this is regulatory data, not something a maker entered. -->
|
||||
<changeSet author="cbsnexora" id="0600_create_prudential_floor_master_table">
|
||||
<createTable tableName="m_prudential_floor">
|
||||
<column autoIncrement="true" name="id" type="BIGINT">
|
||||
<constraints nullable="false" primaryKey="true"/>
|
||||
</column>
|
||||
<column name="product_category" type="VARCHAR(100)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="stage" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="duration_from_days" type="INT" defaultValueComputed="NULL"/>
|
||||
<column name="duration_to_days" type="INT" defaultValueComputed="NULL"/>
|
||||
<column name="secured_floor_pct" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="unsecured_floor_pct" type="DECIMAL(19,6)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="created_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="version" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_by" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_on_utc" type="DATETIME">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
<createIndex indexName="IX_m_prudential_floor_category_stage" tableName="m_prudential_floor">
|
||||
<column name="product_category"/>
|
||||
<column name="stage"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="cbsnexora" id="0600_add_prudential_floor_permission">
|
||||
<insert tableName="m_permission">
|
||||
<column name="grouping" value="organisation"/>
|
||||
<column name="code" value="READ_PRUDENTIAL_FLOOR"/>
|
||||
<column name="entity_name" value="PRUDENTIAL_FLOOR"/>
|
||||
<column name="action_name" value="READ"/>
|
||||
<column name="can_maker_checker" valueBoolean="false"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="cbsnexora" id="0600_seed_prudential_floor_stage_1_2">
|
||||
<!-- Stage 1 / Stage 2: a single floor per category (not split secured/unsecured in the Directions),
|
||||
so secured_floor_pct = unsecured_floor_pct here. -->
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="1.00"/><column name="unsecured_floor_pct" valueNumeric="1.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="5.00"/><column name="unsecured_floor_pct" valueNumeric="5.00"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="1.50"/><column name="unsecured_floor_pct" valueNumeric="1.50"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.25"/><column name="unsecured_floor_pct" valueNumeric="0.25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="1.50"/><column name="unsecured_floor_pct" valueNumeric="1.50"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="1"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="2"/><column name="secured_floor_pct" valueNumeric="0.40"/><column name="unsecured_floor_pct" valueNumeric="0.40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
</changeSet>
|
||||
|
||||
<changeSet author="cbsnexora" id="0600_seed_prudential_floor_stage_3_standard">
|
||||
<!-- Stage 3 ("standard" duration table, para 82(5) first table): applied here to every seeded category
|
||||
alike as a starting point. The Directions in fact vary this table by category group (housing/real
|
||||
estate and unsecured retail use their own bands) - narrowing this per category is a follow-up seed,
|
||||
not a schema change, since duration_from_days/duration_to_days already carry whatever band a row
|
||||
needs. -->
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Secured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Corporate Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Small and Micro Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Medium Enterprises"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Farm Credit - Agricultural Activities"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="40"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="55"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="75"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Gold Loan"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="10"/><column name="unsecured_floor_pct" valueNumeric="25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="duration_to_days" valueNumeric="730"/><column name="secured_floor_pct" valueNumeric="20"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="731"/><column name="duration_to_days" valueNumeric="1095"/><column name="secured_floor_pct" valueNumeric="30"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1096"/><column name="duration_to_days" valueNumeric="1460"/><column name="secured_floor_pct" valueNumeric="40"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Housing Loan - Individual"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="1461"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="duration_to_days" valueNumeric="365"/><column name="secured_floor_pct" valueNumeric="25"/><column name="unsecured_floor_pct" valueNumeric="25"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Unsecured Retail Loans"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="366"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
|
||||
<insert tableName="m_prudential_floor"><column name="product_category" value="Loan against Term Deposit / LIC / KVP"/><column name="stage" valueNumeric="3"/><column name="duration_from_days" valueNumeric="0"/><column name="secured_floor_pct" valueNumeric="100"/><column name="unsecured_floor_pct" valueNumeric="100"/><column name="created_by" valueNumeric="1"/><column name="created_on_utc" valueComputed="NOW()"/><column name="version" valueNumeric="1"/><column name="last_modified_by" valueNumeric="1"/><column name="last_modified_on_utc" valueComputed="NOW()"/></insert>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-49
@@ -1,49 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- RBI 2026 ECL framework, Penal/SMA/NPA wizard step: SICR trigger configuration and the LGD backstop /
|
||||
own-model override. The prudential floor % itself is NOT stored here - it is looked up from
|
||||
m_prudential_floor by product category, so every product stays in sync with a single regulatory table. -->
|
||||
<changeSet author="cbsnexora" id="0601_add_loan_product_advance_ext_sicr_lgd_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<!-- SICR trigger configuration -->
|
||||
<column name="sicr_days_threshold" type="INT"/>
|
||||
<column name="sicr_revolving_days_threshold" type="INT"/>
|
||||
<column name="sicr_rating_downgrade_notches" type="INT"/>
|
||||
<column name="sicr_pricing_increase_trigger_pct" type="DECIMAL(19,6)"/>
|
||||
<column name="sicr_assessment_basis" type="VARCHAR(30)"/>
|
||||
<column name="sicr_rebuttal_allowed" type="BOOLEAN"/>
|
||||
<!-- NPA trigger extras -->
|
||||
<column name="npa_out_of_order_basis" type="VARCHAR(60)"/>
|
||||
<column name="crop_duration" type="VARCHAR(20)"/>
|
||||
<!-- LGD backstop / override -->
|
||||
<column name="use_own_lgd_model" type="BOOLEAN"/>
|
||||
<column name="lgd_secured_pct" type="DECIMAL(19,6)"/>
|
||||
<column name="lgd_unsecured_pct" type="DECIMAL(19,6)"/>
|
||||
<column name="eligible_collateral_type" type="VARCHAR(200)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Loan against Gold: valuation basis, appraiser rule, safe custody and auction notice. Pledge (not lien) security. -->
|
||||
<changeSet author="cbsnexora" id="0604_add_gold_loan_product_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="gold_max_ltv_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="gold_min_purity_carat" type="INT"/>
|
||||
<column name="gold_rate_source" type="VARCHAR(40)"/>
|
||||
<column name="gold_max_per_gram" type="DECIMAL(19,2)"/>
|
||||
<column name="gold_min_net_weight_g" type="DECIMAL(10,2)"/>
|
||||
<column name="gold_max_net_weight_per_customer_g" type="DECIMAL(10,2)"/>
|
||||
<column name="gold_allowed_items" type="VARCHAR(40)"/>
|
||||
<column name="gold_appraiser_required" type="BOOLEAN"/>
|
||||
<column name="gold_valuation_certificate_required" type="BOOLEAN"/>
|
||||
<column name="gold_max_valuation_per_appraiser" type="DECIMAL(19,2)"/>
|
||||
<column name="gold_packet_prefix" type="VARCHAR(20)"/>
|
||||
<column name="gold_auction_notice_days" type="INT"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+294
@@ -0,0 +1,294 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<!--
|
||||
CIC / Credit Bureau Enquiry module (CIBIL TransUnion, Experian, Equifax, CRIF High Mark).
|
||||
|
||||
m_cic_bureau - the bank's bureau master: whitelisted or not, environment, connection and credentials
|
||||
(the secret is stored encrypted with the tenant master password).
|
||||
m_cic_bureau_endpoint - per bureau and enquiry type: method, path, headers and a base JSON body.
|
||||
m_cic_field_mapping - the API mapping, maintained from the UI: REQUEST rows put a CBS field into the bureau's
|
||||
request at a JSON path (with a transform); RESPONSE rows read a JSON path of the bureau's
|
||||
response into a CBS result field (score, accounts, overdue ...).
|
||||
m_cic_consent - the borrower's consent to a bureau pull, required before any enquiry.
|
||||
m_cic_enquiry - every enquiry: masked request, raw response, normalised result, score and status.
|
||||
|
||||
Every bureau is seeded NOT whitelisted, in SIMULATOR mode, with a generic request/response mapping the built-in
|
||||
simulator answers to. Going live is: enter the bureau's URL and credentials, adapt the mapping to the bureau's own
|
||||
specification, switch the environment, and whitelist it.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="0604-1-cic-tables" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cic_bureau"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cic_bureau">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="code" type="VARCHAR(30)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_bureau_code"/></column>
|
||||
<column name="name" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="description" type="VARCHAR(500)"/>
|
||||
<column name="whitelisted" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="is_default" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="environment" type="VARCHAR(20)" defaultValue="SIMULATOR"><constraints nullable="false"/></column>
|
||||
<column name="base_url" type="VARCHAR(500)"/>
|
||||
<column name="member_code" type="VARCHAR(100)"/>
|
||||
<column name="auth_type" type="VARCHAR(40)" defaultValue="NONE"><constraints nullable="false"/></column>
|
||||
<column name="username" type="VARCHAR(200)"/>
|
||||
<column name="api_key_header" type="VARCHAR(100)" defaultValue="X-API-KEY"/>
|
||||
<column name="secret_enc" type="TEXT"/>
|
||||
<column name="token_url" type="VARCHAR(500)"/>
|
||||
<column name="client_id" type="VARCHAR(200)"/>
|
||||
<column name="timeout_ms" type="INT" defaultValueNumeric="30000"><constraints nullable="false"/></column>
|
||||
<column name="report_validity_days" type="INT" defaultValueNumeric="30"><constraints nullable="false"/></column>
|
||||
<column name="enquiry_charge" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="last_test_status" type="VARCHAR(20)"/>
|
||||
<column name="last_test_message" type="VARCHAR(1000)"/>
|
||||
<column name="last_tested_on" type="TIMESTAMP"/>
|
||||
<column name="updated_by" type="BIGINT"/>
|
||||
<column name="updated_on" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
|
||||
<createTable tableName="m_cic_bureau_endpoint">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_endpoint_bureau" references="m_cic_bureau(id)"/></column>
|
||||
<column name="endpoint_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="http_method" type="VARCHAR(10)" defaultValue="POST"><constraints nullable="false"/></column>
|
||||
<column name="path" type="VARCHAR(500)"/>
|
||||
<column name="content_type" type="VARCHAR(100)" defaultValue="application/json"/>
|
||||
<column name="headers_json" type="TEXT"/>
|
||||
<column name="base_body" type="TEXT"/>
|
||||
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
|
||||
<column name="updated_by" type="BIGINT"/>
|
||||
<column name="updated_on" type="TIMESTAMP"/>
|
||||
</createTable>
|
||||
<addUniqueConstraint tableName="m_cic_bureau_endpoint" columnNames="bureau_id, endpoint_type" constraintName="uq_cic_endpoint_type"/>
|
||||
|
||||
<createTable tableName="m_cic_field_mapping">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="endpoint_id" type="BIGINT"><constraints nullable="false" foreignKeyName="fk_cic_mapping_endpoint" references="m_cic_bureau_endpoint(id)" deleteCascade="true"/></column>
|
||||
<column name="direction" type="VARCHAR(10)"><constraints nullable="false"/></column>
|
||||
<column name="source_field" type="VARCHAR(200)"/>
|
||||
<column name="target_field" type="VARCHAR(200)"><constraints nullable="false"/></column>
|
||||
<column name="transform" type="VARCHAR(40)" defaultValue="NONE"/>
|
||||
<column name="default_value" type="VARCHAR(200)"/>
|
||||
<column name="is_mandatory" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
|
||||
<column name="sort_order" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cic_field_mapping" indexName="idx_cic_mapping_endpoint"><column name="endpoint_id"/></createIndex>
|
||||
|
||||
<createTable tableName="m_cic_consent">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="consent_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_consent_ref"/></column>
|
||||
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="client_name" type="VARCHAR(200)"/>
|
||||
<column name="application_no" type="VARCHAR(50)"/>
|
||||
<column name="bureau_id" type="BIGINT"/>
|
||||
<column name="purpose" type="VARCHAR(40)"><constraints nullable="false"/></column>
|
||||
<column name="consent_mode" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="consent_text" type="TEXT"/>
|
||||
<column name="document_ref" type="VARCHAR(200)"/>
|
||||
<column name="otp_reference" type="VARCHAR(100)"/>
|
||||
<column name="valid_from" type="DATE"><constraints nullable="false"/></column>
|
||||
<column name="valid_until" type="DATE"><constraints nullable="false"/></column>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE"><constraints nullable="false"/></column>
|
||||
<column name="revoked_reason" type="VARCHAR(500)"/>
|
||||
<column name="revoked_by" type="BIGINT"/>
|
||||
<column name="revoked_on" type="TIMESTAMP"/>
|
||||
<column name="captured_by" type="BIGINT"/>
|
||||
<column name="captured_by_name" type="VARCHAR(200)"/>
|
||||
<column name="captured_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="ip_address" type="VARCHAR(64)"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cic_consent" indexName="idx_cic_consent_client"><column name="client_id"/></createIndex>
|
||||
|
||||
<createTable tableName="m_cic_enquiry">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="enquiry_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_enquiry_ref"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||
<column name="client_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="client_name" type="VARCHAR(200)"/>
|
||||
<column name="application_no" type="VARCHAR(50)"/>
|
||||
<column name="consent_id" type="BIGINT"/>
|
||||
<column name="enquiry_type" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="purpose" type="VARCHAR(40)"/>
|
||||
<column name="loan_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="score" type="INT"/>
|
||||
<column name="http_status" type="INT"/>
|
||||
<column name="error_message" type="VARCHAR(2000)"/>
|
||||
<column name="request_masked" type="TEXT"/>
|
||||
<column name="response_raw" type="TEXT"/>
|
||||
<column name="result_json" type="TEXT"/>
|
||||
<column name="reused_from_id" type="BIGINT"/>
|
||||
<column name="charge_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="environment" type="VARCHAR(20)"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="requested_by" type="BIGINT"/>
|
||||
<column name="requested_by_name" type="VARCHAR(200)"/>
|
||||
<column name="requested_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="duration_ms" type="INT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_client"><column name="client_id"/></createIndex>
|
||||
<createIndex tableName="m_cic_enquiry" indexName="idx_cic_enquiry_bureau_date"><column name="bureau_id"/><column name="requested_on"/></createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- The four RBI-licensed CICs. Seeded off (not whitelisted) and in SIMULATOR mode. -->
|
||||
<changeSet id="0604-2-cic-bureaus" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau</sqlCheck>
|
||||
</preConditions>
|
||||
<insert tableName="m_cic_bureau">
|
||||
<column name="code" value="CIBIL"/><column name="name" value="TransUnion CIBIL"/>
|
||||
<column name="description" value="TransUnion CIBIL Limited - consumer and commercial credit information"/>
|
||||
<column name="is_default" valueBoolean="true"/>
|
||||
</insert>
|
||||
<insert tableName="m_cic_bureau">
|
||||
<column name="code" value="EXPERIAN"/><column name="name" value="Experian Credit Information Company of India"/>
|
||||
<column name="description" value="Experian India - consumer and commercial credit information"/>
|
||||
</insert>
|
||||
<insert tableName="m_cic_bureau">
|
||||
<column name="code" value="EQUIFAX"/><column name="name" value="Equifax Credit Information Services"/>
|
||||
<column name="description" value="Equifax India - consumer, commercial and microfinance credit information"/>
|
||||
</insert>
|
||||
<insert tableName="m_cic_bureau">
|
||||
<column name="code" value="CRIF"/><column name="name" value="CRIF High Mark Credit Information Services"/>
|
||||
<column name="description" value="CRIF High Mark - consumer, commercial and microfinance credit information"/>
|
||||
</insert>
|
||||
</changeSet>
|
||||
|
||||
<!-- A CONSUMER_ENQUIRY endpoint per bureau, with a generic body skeleton the simulator understands. -->
|
||||
<changeSet id="0604-3-cic-endpoints" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_bureau_endpoint</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
|
||||
SELECT b.id, 'CONSUMER_ENQUIRY', 'POST', '/consumer/enquiry', 'application/json',
|
||||
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{enquiry.reference}}"}',
|
||||
'{"requestHeader":{"product":"CONSUMER_REPORT","version":"1.0"},"consumer":{},"enquiry":{}}',
|
||||
TRUE
|
||||
FROM m_cic_bureau b
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- The generic API mapping. Adapt it to each bureau's own specification from the API Mapping screen. -->
|
||||
<changeSet id="0604-4-cic-mappings" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_cic_field_mapping</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
|
||||
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
|
||||
FROM m_cic_bureau_endpoint e
|
||||
CROSS JOIN (
|
||||
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
|
||||
UNION ALL SELECT 'REQUEST', 'enquiry.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
|
||||
UNION ALL SELECT 'REQUEST', 'enquiry.date', 'requestHeader.enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 30
|
||||
UNION ALL SELECT 'REQUEST', 'client.firstName', 'consumer.name.firstName', 'UPPER', NULL, TRUE, 40
|
||||
UNION ALL SELECT 'REQUEST', 'client.middleName', 'consumer.name.middleName', 'UPPER', NULL, FALSE, 50
|
||||
UNION ALL SELECT 'REQUEST', 'client.lastName', 'consumer.name.lastName', 'UPPER', NULL, FALSE, 60
|
||||
UNION ALL SELECT 'REQUEST', 'client.dob', 'consumer.dateOfBirth', 'DATE_DDMMYYYY', NULL, TRUE, 70
|
||||
UNION ALL SELECT 'REQUEST', 'client.gender', 'consumer.gender', 'GENDER_MF', NULL, FALSE, 80
|
||||
UNION ALL SELECT 'REQUEST', NULL, 'consumer.ids[0].type', 'NONE', 'PAN', FALSE, 90
|
||||
UNION ALL SELECT 'REQUEST', 'client.pan', 'consumer.ids[0].number', 'UPPER', NULL, TRUE, 100
|
||||
UNION ALL SELECT 'REQUEST', NULL, 'consumer.phones[0].type', 'NONE', 'MOBILE', FALSE, 110
|
||||
UNION ALL SELECT 'REQUEST', 'client.mobile', 'consumer.phones[0].number', 'DIGITS_ONLY', NULL, FALSE, 120
|
||||
UNION ALL SELECT 'REQUEST', 'client.email', 'consumer.email', 'LOWER', NULL, FALSE, 130
|
||||
UNION ALL SELECT 'REQUEST', 'client.addressLine1', 'consumer.address.line1', 'UPPER', NULL, FALSE, 140
|
||||
UNION ALL SELECT 'REQUEST', 'client.addressLine2', 'consumer.address.line2', 'UPPER', NULL, FALSE, 150
|
||||
UNION ALL SELECT 'REQUEST', 'client.city', 'consumer.address.city', 'UPPER', NULL, FALSE, 160
|
||||
UNION ALL SELECT 'REQUEST', 'client.state', 'consumer.address.stateCode', 'STATE_CODE', NULL, FALSE, 170
|
||||
UNION ALL SELECT 'REQUEST', 'client.pincode', 'consumer.address.pincode', 'DIGITS_ONLY', NULL, FALSE, 180
|
||||
UNION ALL SELECT 'REQUEST', 'enquiry.amount', 'enquiry.amount', 'AMOUNT_INTEGER', NULL, TRUE, 190
|
||||
UNION ALL SELECT 'REQUEST', 'enquiry.purposeCode', 'enquiry.purpose', 'NONE', NULL, TRUE, 200
|
||||
UNION ALL SELECT 'RESPONSE', 'report.hit', 'hit', 'NONE', NULL, FALSE, 10
|
||||
UNION ALL SELECT 'RESPONSE', 'report.reportId', 'reportId', 'NONE', NULL, FALSE, 20
|
||||
UNION ALL SELECT 'RESPONSE', 'report.score.value', 'score', 'NUMBER', NULL, FALSE, 30
|
||||
UNION ALL SELECT 'RESPONSE', 'report.score.version', 'scoreVersion', 'NONE', NULL, FALSE, 40
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.totalAccounts', 'totalAccounts', 'NUMBER', NULL, FALSE, 50
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.activeAccounts', 'activeAccounts', 'NUMBER', NULL, FALSE, 60
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.overdueAccounts', 'overdueAccounts', 'NUMBER', NULL, FALSE, 70
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.writtenOffAccounts', 'writtenOffAccounts', 'NUMBER', NULL, FALSE, 80
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOutstanding', 'totalOutstanding', 'NUMBER', NULL, FALSE, 90
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.totalOverdue', 'totalOverdue', 'NUMBER', NULL, FALSE, 100
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.enquiriesLast6Months', 'enquiriesLast6Months', 'NUMBER', NULL, FALSE, 110
|
||||
UNION ALL SELECT 'RESPONSE', 'report.summary.maxDpd', 'maxDpd', 'NUMBER', NULL, FALSE, 120
|
||||
UNION ALL SELECT 'RESPONSE', 'report.accounts', 'accounts', 'NONE', NULL, FALSE, 130
|
||||
UNION ALL SELECT 'RESPONSE', 'report.enquiries', 'enquiries', 'NONE', NULL, FALSE, 140
|
||||
) m
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Permissions. Configuration (UPDATE_CICBUREAU) is left to super users; enquiry and consent go to every role that
|
||||
already works Loan Origination, which also gets the new top-nav menu. -->
|
||||
<changeSet id="0604-5-cic-permissions" author="cbsnexora">
|
||||
<sql>
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'heading_menu', 'MENU_CREDIT_BUREAU', 'MENU', 'CREDIT_BUREAU', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'MENU_CREDIT_BUREAU');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'READ_CICBUREAU', 'CICBUREAU', 'READ', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICBUREAU');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'UPDATE_CICBUREAU', 'CICBUREAU', 'UPDATE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICBUREAU');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'READ_CICENQUIRY', 'CICENQUIRY', 'READ', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICENQUIRY');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'CREATE_CICENQUIRY', 'CICENQUIRY', 'CREATE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICENQUIRY');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'READ_CICCONSENT', 'CICCONSENT', 'READ', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICCONSENT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'CREATE_CICCONSENT', 'CICCONSENT', 'CREATE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CICCONSENT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'REVOKE_CICCONSENT', 'CICCONSENT', 'REVOKE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'REVOKE_CICCONSENT');
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT DISTINCT rp.role_id, p2.id
|
||||
FROM m_role_permission rp
|
||||
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_LOAN_ORIGINATION'
|
||||
JOIN m_permission p2 ON p2.code IN ('MENU_CREDIT_BUREAU', 'READ_CICBUREAU', 'READ_CICENQUIRY', 'CREATE_CICENQUIRY',
|
||||
'READ_CICCONSENT', 'CREATE_CICCONSENT')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0604-6-cic-menu-access" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<and>
|
||||
<tableExists tableName="m_tenant_menu_access"/>
|
||||
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_tenant_menu_access WHERE menu_code = 'MENU_CREDIT_BUREAU'</sqlCheck>
|
||||
</and>
|
||||
</preConditions>
|
||||
<insert tableName="m_tenant_menu_access"><column name="menu_code" value="MENU_CREDIT_BUREAU"/></insert>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-34
@@ -1,34 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Gold loans are short term, so the gold is not revalued on a schedule: it is revalued when the loan turns NPA,
|
||||
and the customer may be charged for it. -->
|
||||
<changeSet author="cbsnexora" id="0605_add_gold_revaluation_rules">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="gold_revalue_on_npa" type="BOOLEAN"/>
|
||||
<column name="gold_revaluation_charge_applicable" type="BOOLEAN"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<!--
|
||||
CIC module, second shape: the bank no longer pulls credit reports. Instead the CIC tells the bank when one of
|
||||
ITS customers was enquired by ANOTHER bank / NBFC - i.e. the customer applied for a loan elsewhere - with the
|
||||
lender, loan type and amount. These "enquiry alerts" are fetched through the same bureau configuration and
|
||||
UI-driven API mapping (endpoint type ENQUIRY_ALERT) and shown on the Credit Bureau dashboard.
|
||||
|
||||
Removed: borrower consent (m_cic_consent), report enquiries (m_cic_enquiry), their permissions, and the
|
||||
CONSUMER_ENQUIRY endpoints / mappings.
|
||||
Added: m_cic_alert (one row per enquiry made on our customer at another lender),
|
||||
m_cic_alert_fetch_run (every fetch / file import with its counts and raw response),
|
||||
alert fetch settings on m_cic_bureau, the ENQUIRY_ALERT endpoint and its generic mapping.
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<changeSet id="0605-1-drop-cic-enquiry-consent" author="cbsnexora">
|
||||
<sql>
|
||||
DROP TABLE IF EXISTS m_cic_enquiry;
|
||||
DROP TABLE IF EXISTS m_cic_consent;
|
||||
DELETE FROM m_cic_field_mapping
|
||||
WHERE endpoint_id IN (SELECT id FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY'));
|
||||
DELETE FROM m_cic_bureau_endpoint WHERE endpoint_type IN ('CONSUMER_ENQUIRY', 'COMMERCIAL_ENQUIRY');
|
||||
DELETE FROM m_role_permission
|
||||
WHERE permission_id IN (SELECT id FROM m_permission
|
||||
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT',
|
||||
'REVOKE_CICCONSENT'));
|
||||
DELETE FROM m_permission
|
||||
WHERE code IN ('READ_CICENQUIRY', 'CREATE_CICENQUIRY', 'READ_CICCONSENT', 'CREATE_CICCONSENT', 'REVOKE_CICCONSENT');
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0605-2-cic-bureau-alert-settings" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_cic_bureau" columnName="report_validity_days"/>
|
||||
<dropColumn tableName="m_cic_bureau" columnName="enquiry_charge"/>
|
||||
<addColumn tableName="m_cic_bureau">
|
||||
<column name="alert_auto_fetch" type="BOOLEAN" defaultValueBoolean="true"><constraints nullable="false"/></column>
|
||||
<column name="alert_fetch_interval_min" type="INT" defaultValueNumeric="60"><constraints nullable="false"/></column>
|
||||
<column name="alert_lookback_days" type="INT" defaultValueNumeric="7"><constraints nullable="false"/></column>
|
||||
<column name="last_fetch_on" type="TIMESTAMP"/>
|
||||
<column name="last_fetch_status" type="VARCHAR(20)"/>
|
||||
<column name="last_fetch_message" type="VARCHAR(1000)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0605-3-cic-alert-tables" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cic_alert"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cic_alert_fetch_run">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="run_ref" type="VARCHAR(40)"><constraints nullable="false" unique="true" uniqueConstraintName="uq_cic_fetch_run_ref"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||
<column name="trigger_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="environment" type="VARCHAR(20)"/>
|
||||
<column name="from_date" type="DATE"/>
|
||||
<column name="to_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="http_status" type="INT"/>
|
||||
<column name="received" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="new_alerts" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="matched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="unmatched" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="duplicates" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="own_bank" type="INT" defaultValueNumeric="0"><constraints nullable="false"/></column>
|
||||
<column name="error_message" type="VARCHAR(2000)"/>
|
||||
<column name="request_masked" type="TEXT"/>
|
||||
<column name="response_raw" type="TEXT"/>
|
||||
<column name="duration_ms" type="INT"/>
|
||||
<column name="triggered_by" type="BIGINT"/>
|
||||
<column name="triggered_by_name" type="VARCHAR(200)"/>
|
||||
<column name="started_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cic_alert_fetch_run" indexName="idx_cic_fetch_run_bureau"><column name="bureau_id"/><column name="started_on"/></createIndex>
|
||||
|
||||
<createTable tableName="m_cic_alert">
|
||||
<column name="id" type="BIGINT" autoIncrement="true"><constraints primaryKey="true" nullable="false"/></column>
|
||||
<column name="bureau_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="bureau_code" type="VARCHAR(30)"/>
|
||||
<column name="alert_key" type="VARCHAR(100)"><constraints nullable="false"/></column>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="client_name" type="VARCHAR(200)"/>
|
||||
<column name="client_account_no" type="VARCHAR(50)"/>
|
||||
<column name="match_type" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="customer_name" type="VARCHAR(200)"/>
|
||||
<column name="pan_masked" type="VARCHAR(20)"/>
|
||||
<column name="aadhaar_masked" type="VARCHAR(20)"/>
|
||||
<column name="customer_ref" type="VARCHAR(100)"/>
|
||||
<column name="date_of_birth" type="DATE"/>
|
||||
<column name="institution_code" type="VARCHAR(100)"/>
|
||||
<column name="institution_name" type="VARCHAR(200)"/>
|
||||
<column name="institution_type" type="VARCHAR(60)"/>
|
||||
<column name="enquiry_date" type="DATE"/>
|
||||
<column name="loan_type" type="VARCHAR(100)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)"/>
|
||||
<column name="purpose" type="VARCHAR(200)"/>
|
||||
<column name="location" type="VARCHAR(200)"/>
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="NEW"><constraints nullable="false"/></column>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
<column name="actioned_by" type="BIGINT"/>
|
||||
<column name="actioned_by_name" type="VARCHAR(200)"/>
|
||||
<column name="actioned_on" type="TIMESTAMP"/>
|
||||
<column name="received_via" type="VARCHAR(20)"/>
|
||||
<column name="fetch_run_id" type="BIGINT"/>
|
||||
<column name="received_on" type="TIMESTAMP"><constraints nullable="false"/></column>
|
||||
<column name="raw_json" type="TEXT"/>
|
||||
</createTable>
|
||||
<addUniqueConstraint tableName="m_cic_alert" columnNames="bureau_id, alert_key" constraintName="uq_cic_alert_key"/>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_client"><column name="client_id"/></createIndex>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_status"><column name="status"/></createIndex>
|
||||
<createIndex tableName="m_cic_alert" indexName="idx_cic_alert_enquiry_date"><column name="enquiry_date"/></createIndex>
|
||||
</changeSet>
|
||||
|
||||
<!-- An ENQUIRY_ALERT endpoint per bureau, with a generic body the built-in simulator answers to. -->
|
||||
<changeSet id="0605-4-cic-alert-endpoints" author="cbsnexora">
|
||||
<sql>
|
||||
INSERT INTO m_cic_bureau_endpoint (bureau_id, endpoint_type, http_method, path, content_type, headers_json, base_body, is_active)
|
||||
SELECT b.id, 'ENQUIRY_ALERT', 'POST', '/monitoring/enquiry-alerts', 'application/json',
|
||||
'{"Accept":"application/json","X-Member-Code":"{{bureau.memberCode}}","X-Request-Id":"{{fetch.reference}}"}',
|
||||
'{"requestHeader":{"product":"ENQUIRY_ALERT","version":"1.0"},"criteria":{}}',
|
||||
TRUE
|
||||
FROM m_cic_bureau b
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_cic_bureau_endpoint e WHERE e.bureau_id = b.id AND e.endpoint_type = 'ENQUIRY_ALERT');
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_cic_field_mapping (endpoint_id, direction, source_field, target_field, transform, default_value, is_mandatory, sort_order)
|
||||
SELECT e.id, m.direction, m.source_field, m.target_field, m.transform, m.default_value, m.is_mandatory, m.sort_order
|
||||
FROM m_cic_bureau_endpoint e
|
||||
CROSS JOIN (
|
||||
SELECT 'REQUEST' AS direction, 'bureau.memberCode' AS source_field, 'requestHeader.memberCode' AS target_field, 'NONE' AS transform, CAST(NULL AS VARCHAR(200)) AS default_value, FALSE AS is_mandatory, 10 AS sort_order
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.reference', 'requestHeader.referenceNumber', 'NONE', NULL, TRUE, 20
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.fromDate', 'criteria.fromDate', 'DATE_DDMMYYYY', NULL, TRUE, 30
|
||||
UNION ALL SELECT 'REQUEST', 'fetch.toDate', 'criteria.toDate', 'DATE_DDMMYYYY', NULL, TRUE, 40
|
||||
UNION ALL SELECT 'REQUEST', NULL, 'criteria.scope', 'NONE', 'OTHER_MEMBERS', FALSE, 50
|
||||
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorCode', 'errorCode', 'NONE', NULL, FALSE, 10
|
||||
UNION ALL SELECT 'RESPONSE', 'responseHeader.errorMessage', 'errorMessage', 'NONE', NULL, FALSE, 20
|
||||
UNION ALL SELECT 'RESPONSE', 'data.alerts', 'alerts', 'NONE', NULL, FALSE, 30
|
||||
UNION ALL SELECT 'RESPONSE', 'alertId', 'alertRef', 'NONE', NULL, FALSE, 40
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.pan', 'pan', 'UPPER', NULL, FALSE, 50
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.aadhaar', 'aadhaar', 'NONE', NULL, FALSE, 60
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.name', 'customerName', 'UPPER', NULL, FALSE, 70
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.dateOfBirth', 'dateOfBirth', 'DATE_DDMMYYYY', NULL, FALSE, 80
|
||||
UNION ALL SELECT 'RESPONSE', 'consumer.memberReference', 'customerRef', 'NONE', NULL, FALSE, 90
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberCode', 'institutionCode', 'NONE', NULL, FALSE, 100
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberName', 'institutionName', 'NONE', NULL, FALSE, 110
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.memberType', 'institutionType', 'NONE', NULL, FALSE, 120
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.date', 'enquiryDate', 'DATE_DDMMYYYY', NULL, FALSE, 130
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.productType', 'loanType', 'NONE', NULL, FALSE, 140
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.amount', 'amount', 'NUMBER', NULL, FALSE, 150
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.purpose', 'purpose', 'NONE', NULL, FALSE, 160
|
||||
UNION ALL SELECT 'RESPONSE', 'enquiry.location', 'location', 'NONE', NULL, FALSE, 170
|
||||
) m
|
||||
WHERE e.endpoint_type = 'ENQUIRY_ALERT'
|
||||
AND NOT EXISTS (SELECT 1 FROM m_cic_field_mapping x WHERE x.endpoint_id = e.id);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
<!-- Alert permissions; every role that has the Credit Bureau menu may read, work and fetch alerts. -->
|
||||
<changeSet id="0605-5-cic-alert-permissions" author="cbsnexora">
|
||||
<sql>
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'READ_CICALERT', 'CICALERT', 'READ', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'READ_CICALERT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'UPDATE_CICALERT', 'CICALERT', 'UPDATE', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'UPDATE_CICALERT');
|
||||
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
|
||||
SELECT 'cic', 'FETCH_CICALERT', 'CICALERT', 'FETCH', FALSE
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'FETCH_CICALERT');
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_role_permission (role_id, permission_id)
|
||||
SELECT DISTINCT rp.role_id, p2.id
|
||||
FROM m_role_permission rp
|
||||
JOIN m_permission p1 ON p1.id = rp.permission_id AND p1.code = 'MENU_CREDIT_BUREAU'
|
||||
JOIN m_permission p2 ON p2.code IN ('READ_CICALERT', 'UPDATE_CICALERT', 'FETCH_CICALERT')
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p2.id)
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-39
@@ -1,39 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Loan against Gold: servicing rules (partial release, third-party gold), disbursement mode with the cash limit,
|
||||
the LTV level that flags a falling gold rate, and the auction reserve price / right to redeem. -->
|
||||
<changeSet author="cbsnexora" id="0606_add_gold_servicing_and_auction_rules">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="gold_partial_release_allowed" type="BOOLEAN"/>
|
||||
<column name="gold_third_party_allowed" type="BOOLEAN"/>
|
||||
<column name="gold_disbursement_mode" type="VARCHAR(40)"/>
|
||||
<column name="gold_cash_disbursement_limit" type="DECIMAL(19,2)"/>
|
||||
<column name="gold_ltv_breach_alert_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="gold_auction_reserve_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="gold_redeem_before_auction" type="BOOLEAN"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||
|
||||
<!--
|
||||
The RD base chart gets a senior-citizen rate on every slab, like the FD one, so a recurring deposit
|
||||
can price a senior citizen from its own chart.
|
||||
-->
|
||||
<changeSet author="fineract" id="1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not>
|
||||
<columnExists tableName="m_rd_base_interest_rate_slab_history" columnName="senior_citizen_rate"/>
|
||||
</not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_rd_base_interest_rate_slab_history">
|
||||
<column name="senior_citizen_rate" type="DECIMAL(19,6)" defaultValueNumeric="0"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<!--
|
||||
RD products used to take their rates from the FD "Double" chart (years) plus, below a year, from this RD
|
||||
chart (days). Recurring deposits now take everything from this chart alone, in months, so it is given one
|
||||
chart that prices exactly what the two did together: the same rates, in months, with the senior-citizen
|
||||
rate the FD chart carried for a year or longer. Below a year no senior rate existed; the same 0.50 premium
|
||||
the longer slabs carry is given to those too. It only runs while the active chart is still the old one in days, and the
|
||||
chart can be edited on the CBS Base Interest Rate screen.
|
||||
-->
|
||||
<changeSet author="fineract" id="2">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
SELECT CASE WHEN EXISTS (
|
||||
SELECT 1 FROM m_rd_base_interest_rate_slab_history s
|
||||
JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id
|
||||
WHERE e.is_active = true AND s.period_type_enum = 1
|
||||
) THEN 1 ELSE 0 END
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_rd_base_interest_rate_effective SET is_active = false, effective_to_date = CURRENT_DATE WHERE is_active = true;
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_rd_base_interest_rate_effective (version_label, effective_from_date, is_active, remarks, created_date)
|
||||
VALUES ('RD-MONTHS-V1', CURRENT_DATE, true, 'RD base chart in months, carrying the senior-citizen rate', CURRENT_DATE);
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 1, '1 to 3 Months', 2, 1, 3, 3, 3.5, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 2, '4 to 5 Months', 2, 4, 5, 4.5, 5, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 3, '6 to 11 Months', 2, 6, 11, 5.5, 6, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 4, '12 to 35 Months', 2, 12, 35, 6.5, 7, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 5, '36 to 71 Months', 2, 36, 71, 6.75, 7.25, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 6, '72 to 131 Months', 2, 72, 131, 7, 7.5, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
INSERT INTO m_rd_base_interest_rate_slab_history
|
||||
(effective_id, sr_no, description, period_type_enum, from_period, to_period, annual_interest_rate, senior_citizen_rate, created_date)
|
||||
SELECT e.id, 7, '132+ Months', 2, 132, NULL, 7.25, 7.75, CURRENT_DATE
|
||||
FROM m_rd_base_interest_rate_effective e WHERE e.version_label = 'RD-MONTHS-V1';
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
-47
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Loan against Warehouse Receipt: the receipt rules (type, WDRA warehouse, assay, validity, repository pledge),
|
||||
the commodity groups and valuation price, stock inspection, LTV alert, and the disposal rules after NPA. -->
|
||||
<changeSet author="cbsnexora" id="0607_add_warehouse_receipt_product_fields">
|
||||
<addColumn tableName="m_product_loan_advance_ext">
|
||||
<column name="wr_max_ltv_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="wr_receipt_type" type="VARCHAR(20)"/>
|
||||
<column name="wr_wdra_registered" type="BOOLEAN"/>
|
||||
<column name="wr_assay_certificate_required" type="BOOLEAN"/>
|
||||
<column name="wr_tenure_within_receipt_validity" type="BOOLEAN"/>
|
||||
<column name="wr_pledge_marked_before_disbursement" type="BOOLEAN"/>
|
||||
<column name="wr_commodity_groups" type="VARCHAR(200)"/>
|
||||
<column name="wr_valuation_price_source" type="VARCHAR(30)"/>
|
||||
<column name="wr_max_loan_per_quintal" type="DECIMAL(19,2)"/>
|
||||
<column name="wr_stock_inspection_months" type="INT"/>
|
||||
<column name="wr_ltv_breach_alert_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="wr_partial_release_allowed" type="BOOLEAN"/>
|
||||
<column name="wr_disposal_notice_days" type="INT"/>
|
||||
<column name="wr_disposal_reserve_pct" type="DECIMAL(6,2)"/>
|
||||
<column name="wr_redeem_before_disposal" type="BOOLEAN"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||
|
||||
<!--
|
||||
Recurring deposit products now take their rates from the RD base chart. A product keeps its own copy of the
|
||||
chart it was created with, so products made before that change still carry the old one, built from the FD
|
||||
"Double" chart. This gives every existing RD product the active RD base chart, once.
|
||||
|
||||
Only the product's chart is replaced. Accounts already open keep the rates they were opened at, and the
|
||||
product's own minimum and maximum terms are untouched. It runs only when the active RD chart is in months
|
||||
or years, the units a product's chart can hold.
|
||||
-->
|
||||
<changeSet author="fineract" id="1">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<sqlCheck expectedResult="1">
|
||||
SELECT CASE WHEN
|
||||
EXISTS (SELECT 1 FROM m_rd_base_interest_rate_effective WHERE is_active = true)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_rd_base_interest_rate_slab_history s
|
||||
JOIN m_rd_base_interest_rate_effective e ON e.id = s.effective_id
|
||||
WHERE e.is_active = true AND s.period_type_enum NOT IN (2, 3))
|
||||
THEN 1 ELSE 0 END
|
||||
</sqlCheck>
|
||||
</preConditions>
|
||||
<sql>
|
||||
UPDATE m_interest_rate_chart
|
||||
SET name = (SELECT MAX(e.version_label) FROM m_rd_base_interest_rate_effective e WHERE e.id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true)),
|
||||
description = 'Active RD CBS base chart'
|
||||
WHERE id IN (
|
||||
SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc
|
||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id
|
||||
WHERE sp.deposit_type_enum = 300);
|
||||
</sql>
|
||||
<sql>
|
||||
DELETE FROM m_interest_rate_slab
|
||||
WHERE interest_rate_chart_id IN (
|
||||
SELECT dpc.interest_rate_chart_id FROM m_deposit_product_interest_rate_chart dpc
|
||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id
|
||||
WHERE sp.deposit_type_enum = 300);
|
||||
</sql>
|
||||
<sql>
|
||||
INSERT INTO m_interest_rate_slab
|
||||
(interest_rate_chart_id, description, period_type_enum, from_period, to_period, annual_interest_rate, currency_code)
|
||||
SELECT dpc.interest_rate_chart_id, s.description, s.period_type_enum, s.from_period, s.to_period, s.annual_interest_rate, sp.currency_code
|
||||
FROM m_deposit_product_interest_rate_chart dpc
|
||||
JOIN m_savings_product sp ON sp.id = dpc.deposit_product_id AND sp.deposit_type_enum = 300
|
||||
JOIN m_rd_base_interest_rate_slab_history s
|
||||
ON s.effective_id = (SELECT MAX(x.id) FROM m_rd_base_interest_rate_effective x WHERE x.is_active = true);
|
||||
</sql>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+14
-7
@@ -21,14 +21,21 @@
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
|
||||
|
||||
<!-- RBI 2026 ECL framework: a delinquency range only ever decided an SMA/NPA label by day-count; it now also
|
||||
records which ECL stage (1, 2 or 3) that label maps to, so a product's bucket can drive staging directly
|
||||
instead of a parallel lookup. Nullable: existing ranges keep working as plain labels until re-classified. -->
|
||||
<changeSet author="cbsnexora" id="0599_add_delinquency_range_stage">
|
||||
<addColumn tableName="m_delinquency_range">
|
||||
<column name="stage" type="INT" defaultValueComputed="NULL"/>
|
||||
<!--
|
||||
A rule authored from the AML Rules Master starts on trial: it is listed first, marked as new, and an officer marks it
|
||||
normal once it has been in use for as long as they want to watch it. Every rule already in the catalog is normal.
|
||||
-->
|
||||
<changeSet author="fineract" id="0608-1-risk-rule-trial">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_client_risk_rule" columnName="on_trial"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_client_risk_rule">
|
||||
<column name="on_trial" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="trial_started_on" type="DATE"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- RBI priority sector lending categories, kept as a Code so Loan Origination and the loan account wizard read
|
||||
the list from the system instead of a fixed list. Names are shown as they are. -->
|
||||
<changeSet author="CBS" id="0608_seed_priority_sector_category_code">
|
||||
<sql>
|
||||
INSERT INTO m_code (code_name, is_system_defined)
|
||||
SELECT 'PrioritySectorCategory', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PrioritySectorCategory');
|
||||
|
||||
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
|
||||
SELECT c.id, v.code_value, v.order_position, true, false
|
||||
FROM m_code c
|
||||
CROSS JOIN (VALUES
|
||||
('Agriculture', 1),
|
||||
('Micro, Small and Medium Enterprises', 2),
|
||||
('Export Credit', 3),
|
||||
('Education', 4),
|
||||
('Housing', 5),
|
||||
('Social Infrastructure', 6),
|
||||
('Renewable Energy', 7),
|
||||
('Others', 8)
|
||||
) AS v(code_value, order_position)
|
||||
WHERE c.code_name = 'PrioritySectorCategory'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_code_value cv
|
||||
WHERE cv.code_id = c.id AND cv.code_value = v.code_value
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
|
||||
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.
|
||||
|
||||
-->
|
||||
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
|
||||
|
||||
<!-- Weaker sections of the RBI Priority Sector Lending Master Directions (para 17.1): an overlapping tag on a priority
|
||||
sector loan, chosen at sanction in Loan Origination. Kept as a Code so the list can be maintained in Admin. -->
|
||||
<changeSet author="CBS" id="0609_seed_weaker_section_type_code">
|
||||
<sql>
|
||||
INSERT INTO m_code (code_name, is_system_defined)
|
||||
SELECT 'WeakerSectionType', false
|
||||
WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'WeakerSectionType');
|
||||
|
||||
INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory)
|
||||
SELECT c.id, v.code_value, v.order_position, true, false
|
||||
FROM m_code c
|
||||
CROSS JOIN (VALUES
|
||||
('Small and Marginal Farmers', 1),
|
||||
('Artisans, village and cottage industries (individual credit limit up to ₹2 lakh)', 2),
|
||||
('Beneficiaries of Government Sponsored Schemes (NRLM, SRMS)', 3),
|
||||
('Scheduled Castes and Scheduled Tribes', 4),
|
||||
('Beneficiaries of Differential Rate of Interest (DRI) scheme', 5),
|
||||
('Self Help Groups / Joint Liability Groups', 6),
|
||||
('Individuals and members of SHGs/JLGs availing microfinance loans', 7),
|
||||
('Individual women beneficiaries (up to ₹2 lakh)', 8),
|
||||
('Distressed farmers indebted to non-institutional lenders', 9),
|
||||
('Distressed persons other than farmers (up to ₹1 lakh)', 10),
|
||||
('Persons with disabilities', 11),
|
||||
('Transgenders', 12),
|
||||
('Minority communities notified by Government of India', 13)
|
||||
) AS v(code_value, order_position)
|
||||
WHERE c.code_name = 'WeakerSectionType'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM m_code_value cv
|
||||
WHERE cv.code_id = c.id AND cv.code_value = v.code_value
|
||||
);
|
||||
</sql>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+2
-2
@@ -173,7 +173,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest {
|
||||
when(loanAccountData.getId()).thenReturn(1L);
|
||||
when(loanAccountData.getAccountNo()).thenReturn("0001");
|
||||
when(loanAccountData.getExternalId()).thenReturn(ExternalIdFactory.produce("externalId"));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10));
|
||||
when(loanAccountData.getCurrency()).thenAnswer(
|
||||
a -> new CurrencyData(loanCurrency.getCode(), loanCurrency.getDigitsAfterDecimal(), loanCurrency.getInMultiplesOf()));
|
||||
when(loanForProcessing.getCurrency()).thenReturn(loanCurrency);
|
||||
@@ -238,7 +238,7 @@ public class LoanAccountDelinquencyRangeEventSerializerTest {
|
||||
when(loanAccountData.getId()).thenReturn(1L);
|
||||
when(loanAccountData.getAccountNo()).thenReturn("0001");
|
||||
when(loanAccountData.getExternalId()).thenReturn(ExternalIdFactory.produce("externalId"));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10));
|
||||
when(loanAccountData.getCurrency()).thenAnswer(
|
||||
a -> new CurrencyData(loanCurrency.getCode(), loanCurrency.getDigitsAfterDecimal(), loanCurrency.getInMultiplesOf()));
|
||||
when(loanForProcessing.getCurrency()).thenReturn(loanCurrency);
|
||||
|
||||
+14
-14
@@ -174,9 +174,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
@@ -217,9 +217,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
@@ -281,9 +281,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
|
||||
@@ -346,9 +346,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
@@ -425,9 +425,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
final List<LoanDelinquencyActionData> effectiveDelinquencyList = Collections.emptyList();
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
@@ -510,9 +510,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
// given
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
LoanProduct loanProduct = Mockito.mock(LoanProduct.class);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
@@ -597,9 +597,9 @@ public class DelinquencyWritePlatformServiceRangeChangeEventTest {
|
||||
// given
|
||||
Loan loanForProcessing = Mockito.mock(Loan.class);
|
||||
when(loanForProcessing.getId()).thenReturn(1L);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
|
||||
-1
@@ -27,5 +27,4 @@ public final class RdInterestRateSlabData {
|
||||
private final Integer fromPeriod;
|
||||
private final Integer toPeriod;
|
||||
private final BigDecimal annualInterestRate;
|
||||
private final BigDecimal seniorCitizenRate;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user