Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95e981bcd | ||
|
|
0191461102 | ||
|
|
4c40784a0e | ||
|
|
6be32272c1 | ||
|
|
b47a34b5ef | ||
|
|
04d1eb20f0 |
+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).notNull().integerGreaterThanZero();
|
||||
.value(incomeFromRecoveryAccountId).ignoreIfNull().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).notNull().integerGreaterThanZero();
|
||||
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
|
||||
}
|
||||
|
||||
|
||||
+13
-3
@@ -46,17 +46,27 @@ 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) {
|
||||
protected DelinquencyRange(@NotNull String classification, @NotNull Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||
this.classification = classification;
|
||||
this.minimumAgeDays = minimumAgeDays;
|
||||
this.maximumAgeDays = maximumAgeDays;
|
||||
this.stage = stage;
|
||||
}
|
||||
|
||||
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge) {
|
||||
return new DelinquencyRange(classification, minimumAge, maximumAge);
|
||||
public static DelinquencyRange instance(@NotNull String classification, @NotNull Integer minimumAge, Integer maximumAge,
|
||||
Integer stage) {
|
||||
return new DelinquencyRange(classification, minimumAge, maximumAge, stage);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+17
@@ -53,12 +53,20 @@ 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);
|
||||
}
|
||||
|
||||
@@ -72,16 +80,25 @@ 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,6 +29,7 @@ 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,5 +35,6 @@ public class DelinquencyRangeRequest implements Serializable {
|
||||
private String classification;
|
||||
private Integer minimumAgeDays;
|
||||
private Integer maximumAgeDays;
|
||||
private Integer stage;
|
||||
private String locale;
|
||||
}
|
||||
|
||||
+5
-3
@@ -34,13 +34,15 @@ 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) {
|
||||
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays);
|
||||
public static DelinquencyRangeData instance(String classification, Integer minimumAgeDays, Integer maximumAgeDays, Integer stage) {
|
||||
return new DelinquencyRangeData(null, classification, minimumAgeDays, maximumAgeDays, stage);
|
||||
}
|
||||
|
||||
public static DelinquencyRangeData reference(Long id) {
|
||||
return new DelinquencyRangeData(id, "", 0, 0);
|
||||
return new DelinquencyRangeData(id, "", 0, 0, null);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+5
-1
@@ -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.getMaximumAgeDays(), data.getStage());
|
||||
return repositoryRange.saveAndFlush(newDelinquencyRange);
|
||||
} else {
|
||||
throw new PlatformDataIntegrityException("error.msg.data.integrity.issue.entity.duplicated",
|
||||
@@ -327,6 +327,10 @@ 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);
|
||||
}
|
||||
|
||||
+5
-2
@@ -56,7 +56,8 @@ public class DelinquencyRangeParseAndValidator extends ParseAndValidator {
|
||||
|
||||
jsonHelper.checkForUnsupportedParameters(element,
|
||||
List.of(DelinquencyApiConstants.CLASSIFICATION_PARAM_NAME, DelinquencyApiConstants.MINIMUMAGEDAYS_PARAM_NAME,
|
||||
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.LOCALE_PARAM_NAME));
|
||||
DelinquencyApiConstants.MAXIMUMAGEDAYS_PARAM_NAME, DelinquencyApiConstants.STAGE_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();
|
||||
@@ -65,13 +66,15 @@ 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);
|
||||
return dataValidator.hasError() ? null : DelinquencyRangeData.instance(classification, minimumAge, maximumAge, stage);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+172
@@ -137,6 +137,141 @@ 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;
|
||||
|
||||
@@ -464,6 +599,43 @@ 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
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
+11
@@ -79,6 +79,17 @@ 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,6 +33,9 @@ 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);
|
||||
|
||||
+8
-1
@@ -214,10 +214,17 @@ 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"))) {
|
||||
if ((!useType.equals("audit") && !useType.equals("makerchecker") && !useType.equals("makerchecker-mine"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.invalid.auditSearchTemplate.useType",
|
||||
"Invalid Audit Search Template UseType: " + useType);
|
||||
}
|
||||
|
||||
+14
-1
@@ -20,6 +20,7 @@ import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.cashreport.service.VaultPositionService;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger;
|
||||
import org.apache.fineract.portfolio.denominationmaster.service.DenominationBreakdownWriter;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
@@ -36,6 +37,7 @@ public class CashReportApiResource {
|
||||
private final DenominationBreakdownWriter denominationBreakdownWriter;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final VoucherService voucherService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
@@ -329,7 +331,18 @@ public class CashReportApiResource {
|
||||
BigDecimal priorCreditTotal = BigDecimal.ZERO;
|
||||
BigDecimal priorDebitTotal = BigDecimal.ZERO;
|
||||
|
||||
// Where each voucher stands after a correction: a revoked voucher is listed, marked, but its money now sits on
|
||||
// the voucher that replaced it, so it must not be counted in the day's totals a second time.
|
||||
final Map<String, Map<String, Object>> marks = voucherService.voucherMarks(
|
||||
rows.stream().map(r -> (String) r.get("voucherNo")).toList());
|
||||
|
||||
for (final Map<String, Object> row : rows) {
|
||||
final Map<String, Object> mark = marks.get((String) row.get("voucherNo"));
|
||||
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
|
||||
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
|
||||
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
|
||||
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
|
||||
final boolean revoked = "REVOKED".equals(row.get("voucherStatus"));
|
||||
if (sideFilter != null && !sideFilter.equals(row.get("side"))) {
|
||||
continue;
|
||||
}
|
||||
@@ -350,7 +363,7 @@ public class CashReportApiResource {
|
||||
// was already earned, and the day it belongs to has been counted and signed off. Adding
|
||||
// it to this day's totals would make them disagree with the ledger for either date.
|
||||
final boolean priorDay = "SYSTEM_PRIOR".equals(row.get("section"));
|
||||
if (!priorDay && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
if (!priorDay && !revoked && ("APPROVED".equalsIgnoreCase(rowStatus) || "COMPLETED".equalsIgnoreCase(rowStatus)
|
||||
|| "WITHDRAWN".equalsIgnoreCase(rowStatus) || "CLOSED".equalsIgnoreCase(rowStatus))) {
|
||||
final BigDecimal amount = (BigDecimal) row.get("amount");
|
||||
if (amount != null) {
|
||||
|
||||
+186
-10
@@ -57,9 +57,23 @@ public class CashTransferApiResource {
|
||||
private final VaultPositionService vaultPositionService;
|
||||
private final Gson gson = new Gson();
|
||||
|
||||
/**
|
||||
* Cash no longer leaves the vault on one person's say-so: the allocation is raised as a request (see
|
||||
* {@link #submitRequest}) and only a different person's approval posts it. The posting itself is
|
||||
* {@link #postTransfer}.
|
||||
*/
|
||||
@POST
|
||||
public String create(String body) {
|
||||
this.context.authenticatedUser().validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.required",
|
||||
"A cash transfer is raised as a request and posted when a manager approves it.");
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a transfer and, unless {@code dryRun}, posts it: the header and its denominations, then the GL entry.
|
||||
* A dry run stops after every check has passed, which is what a request is held to when it is raised and again
|
||||
* when it is approved, since the vault can change in between.
|
||||
*/
|
||||
private String postTransfer(final String body, final Long performedBy, final String performedByUsername, final boolean dryRun) {
|
||||
JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
|
||||
final LocalDate transferDate = json.has("transferDate") && !json.get("transferDate").isJsonNull()
|
||||
@@ -102,6 +116,9 @@ public class CashTransferApiResource {
|
||||
}
|
||||
|
||||
validateVaultHasSufficientCash(fromTellerId, total, denominations, transferDate);
|
||||
if (dryRun) {
|
||||
return "{}";
|
||||
}
|
||||
|
||||
CashTransfer header = new CashTransfer();
|
||||
header.setTransferDate(transferDate);
|
||||
@@ -112,15 +129,9 @@ public class CashTransferApiResource {
|
||||
header.setTransferAmount(total);
|
||||
header.setNote(note);
|
||||
header.setCreatedAt(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
|
||||
try {
|
||||
AppUser user = this.context.authenticatedUser();
|
||||
if (user != null) {
|
||||
header.setPerformedBy(user.getId());
|
||||
header.setPerformedByUsername(user.getUsername());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// resolving the current user must never block recording the transfer
|
||||
}
|
||||
// Recorded against the person who raised the request, not the one who approved it.
|
||||
header.setPerformedBy(performedBy);
|
||||
header.setPerformedByUsername(performedByUsername);
|
||||
|
||||
// Save header + details in one committed transaction, then GL dispatch separately.
|
||||
// This prevents EclipseLink from flushing pending detail rows inside Fineract's
|
||||
@@ -159,6 +170,171 @@ public class CashTransferApiResource {
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
// ── Maker-checker: the allocation is a request until a different person approves it ────────────
|
||||
|
||||
/** The maker raises the allocation. Nothing moves yet: every check runs, and the request waits for a checker. */
|
||||
@POST
|
||||
@Path("requests")
|
||||
public String submitRequest(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
user.validateHasPermissionTo("CREATE_CASHTRANSFER");
|
||||
postTransfer(body, user.getId(), user.getUsername(), true);
|
||||
|
||||
// The same request, from the same person, is not raised twice while the first is still waiting: a second tap or
|
||||
// a second click would otherwise release the same cash twice once both were approved.
|
||||
final Integer identical = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_cash_transfer_request WHERE status = ? AND maker_id = ? AND request_body = ?", Integer.class,
|
||||
REQUEST_PENDING, user.getId(), body);
|
||||
if (identical != null && identical > 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.duplicate",
|
||||
"An identical cash transfer request from you is already waiting for approval.");
|
||||
}
|
||||
|
||||
final JsonObject json = JsonParser.parseString(body).getAsJsonObject();
|
||||
final JsonArray denominations = json.getAsJsonArray("denominations");
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_cash_transfer_request
|
||||
(transfer_date, from_teller_id, to_cashier_id, from_party, to_party, note, total_amount, request_body,
|
||||
status, maker_id, maker_name, maker_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, textOrNull(json, "transferDate") == null ? null : java.sql.Date.valueOf(textOrNull(json, "transferDate")),
|
||||
json.get("fromTellerId").getAsLong(), json.get("toCashierId").getAsLong(), textOrNull(json, "fromParty"),
|
||||
textOrNull(json, "toParty"), textOrNull(json, "note"), sumTotal(denominations), body, REQUEST_PENDING, user.getId(),
|
||||
user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()));
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("status", REQUEST_PENDING);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
/** Requests by status (comma separated); {@code mine} narrows them to the ones the caller raised. */
|
||||
@GET
|
||||
@Path("requests")
|
||||
public String listRequests(@QueryParam("status") final String status,
|
||||
@QueryParam("mine") @jakarta.ws.rs.DefaultValue("false") final boolean mine) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_cash_transfer_request WHERE 1=1");
|
||||
final List<Object> params = new java.util.ArrayList<>();
|
||||
if (status != null && !status.isBlank()) {
|
||||
final String[] statuses = status.split(",");
|
||||
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
|
||||
for (final String s : statuses) {
|
||||
params.add(s.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
if (mine) {
|
||||
sql.append(" AND maker_id = ?");
|
||||
params.add(user.getId());
|
||||
}
|
||||
sql.append(" ORDER BY id DESC LIMIT 200");
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
for (final Map<String, Object> row : rows) {
|
||||
// The notes asked for, so the approver sees what they are releasing without opening anything.
|
||||
final Object requestBody = row.remove("request_body");
|
||||
row.put("denominations", requestBody == null ? new JsonArray()
|
||||
: JsonParser.parseString(requestBody.toString()).getAsJsonObject().getAsJsonArray("denominations"));
|
||||
}
|
||||
return gson.toJson(rows);
|
||||
}
|
||||
|
||||
/** The checker releases the cash: re-checks the vault, then posts the transfer exactly as a direct one used to. */
|
||||
@POST
|
||||
@Path("requests/{id}/approve")
|
||||
public String approveRequest(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireChecker(user);
|
||||
final Map<String, Object> r = loadRequest(id);
|
||||
requirePending(r, "approved");
|
||||
final Long makerId = r.get("maker_id") instanceof Number n ? n.longValue() : null;
|
||||
if (makerId != null && makerId.equals(user.getId())) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.same.user",
|
||||
"Maker-checker: you cannot approve a cash transfer you raised yourself. Ask another manager.");
|
||||
}
|
||||
final String posted = postTransfer((String) r.get("request_body"), makerId, (String) r.get("maker_name"), false);
|
||||
final JsonObject postedJson = JsonParser.parseString(posted).getAsJsonObject();
|
||||
final Long transferId = postedJson.has("resourceId") ? postedJson.get("resourceId").getAsLong() : null;
|
||||
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, cash_transfer_id = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REQUEST_APPROVED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||
transferId, id, REQUEST_PENDING);
|
||||
if (updated == 0) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.actioned",
|
||||
"This request was already actioned by another manager.");
|
||||
}
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("status", REQUEST_APPROVED);
|
||||
if (transferId != null) {
|
||||
result.addProperty("cashTransferId", transferId);
|
||||
}
|
||||
if (postedJson.has("glWarning")) {
|
||||
result.add("glWarning", postedJson.get("glWarning"));
|
||||
}
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("requests/{id}/reject")
|
||||
public String rejectRequest(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireChecker(user);
|
||||
final Map<String, Object> r = loadRequest(id);
|
||||
requirePending(r, "rejected");
|
||||
final JsonObject json = body == null || body.isBlank() ? new JsonObject() : JsonParser.parseString(body).getAsJsonObject();
|
||||
final String reason = textOrNull(json, "reason");
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.reason", "A reason is required to reject.");
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_cash_transfer_request SET status = ?, checker_id = ?, checker_name = ?, checker_at = ?, rejection_reason = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", REQUEST_REJECTED, user.getId(), user.getDisplayName(), java.sql.Timestamp.from(java.time.Instant.now()),
|
||||
reason.trim(), id, REQUEST_PENDING);
|
||||
final JsonObject result = new JsonObject();
|
||||
result.addProperty("resourceId", id);
|
||||
result.addProperty("status", REQUEST_REJECTED);
|
||||
return gson.toJson(result);
|
||||
}
|
||||
|
||||
private static final String REQUEST_PENDING = "PENDING";
|
||||
private static final String REQUEST_APPROVED = "APPROVED";
|
||||
private static final String REQUEST_REJECTED = "REJECTED";
|
||||
|
||||
private Map<String, Object> loadRequest(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_cash_transfer_request WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.not.found", "Cash transfer request " + id + " not found.");
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private void requirePending(final Map<String, Object> request, final String action) {
|
||||
if (!REQUEST_PENDING.equals(request.get("status"))) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.status",
|
||||
"Only PENDING requests can be " + action + ". Current status: " + request.get("status"));
|
||||
}
|
||||
}
|
||||
|
||||
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
|
||||
private void requireChecker(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw new PlatformDataIntegrityException("error.msg.cashtransfer.request.checker",
|
||||
"Only a Bank Manager (or Head Cashier) can approve or reject a cash transfer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static String textOrNull(final JsonObject json, final String key) {
|
||||
return json.has(key) && !json.get(key).isJsonNull() ? json.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
@GET
|
||||
public String getAll(
|
||||
@QueryParam("offset") @jakarta.ws.rs.DefaultValue("0") int offset,
|
||||
|
||||
+158
-16
@@ -48,6 +48,7 @@ import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
@@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root);
|
||||
final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root);
|
||||
final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root);
|
||||
// A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is
|
||||
// paid from instead of a savings account; it is approved and disbursed exactly like any other slip.
|
||||
final boolean isGl = "GL".equalsIgnoreCase(accountType);
|
||||
final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root);
|
||||
final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root);
|
||||
final Long officeId = fromJsonHelper.extractLongNamed("officeId", root);
|
||||
final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root);
|
||||
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "Account number is required");
|
||||
}
|
||||
if (savingsAccountId == null) {
|
||||
if (isGl) {
|
||||
validateGlWithdrawalAccounts(glAccountId, contraGlId);
|
||||
} else if (savingsAccountId == null) {
|
||||
throw apiError(400, "Savings account id is required for a withdrawal request");
|
||||
}
|
||||
if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
@@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
throw apiError(400, "Token number (TOK. NO.) is required");
|
||||
}
|
||||
validateTokenUniqueToday(tokNo, null);
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
if (!isGl) {
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
}
|
||||
|
||||
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||
|
||||
@@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
INSERT INTO m_clerk_withdrawal_request
|
||||
(account_no, account_type, savings_account_id, client_id, client_name, amount,
|
||||
payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date,
|
||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""",
|
||||
Long.class,
|
||||
@@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txnDate != null ? Date.valueOf(txnDate) : null,
|
||||
"PENDING",
|
||||
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
|
||||
denominationData, activityLog, Timestamp.from(Instant.now())
|
||||
denominationData, activityLog, Timestamp.from(Instant.now()),
|
||||
isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null,
|
||||
isGl ? (currencyCode != null ? currencyCode : "INR") : null
|
||||
);
|
||||
|
||||
// Globally unique voucher number from the central m_voucher register
|
||||
@@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
+ " txn_date, status, clerk_id, clerk_name, clerk_username,"
|
||||
+ " manager_id, manager_name, manager_username, rejection_reason, txn_id,"
|
||||
+ " denomination_data, activity_log, submitted_at, actioned_at,"
|
||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no"
|
||||
+ " cashier_id, disbursed_by, disbursed_at, voucher_no,"
|
||||
+ " gl_account_id, contra_gl_id, office_id, currency_code"
|
||||
+ " FROM m_clerk_withdrawal_request " + where
|
||||
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray());
|
||||
@@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
payment_type_name, description, tok_no, txn_date, status,
|
||||
clerk_name, manager_name, manager_username, rejection_reason, txn_id,
|
||||
submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request
|
||||
""" + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
|
||||
pageParams.toArray()
|
||||
@@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
@@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
amount, payment_type_id, payment_type_name, description, tok_no, status,
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no
|
||||
clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request
|
||||
WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE
|
||||
ORDER BY submitted_at DESC LIMIT 1
|
||||
@@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
"Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it.");
|
||||
}
|
||||
|
||||
final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type"));
|
||||
final Long savingsAccountId = record.get("savings_account_id") != null
|
||||
? ((Number) record.get("savings_account_id")).longValue() : null;
|
||||
// Checked again on approval, not only on submission: a request can sit in the queue for days,
|
||||
// and the account can cross the dormancy threshold while it waits. The savings withdrawal
|
||||
// below refuses a dormant account anyway, but that failure would surface to the manager as a
|
||||
// rejected transaction after the approval was recorded; this says why, before anything moves.
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
if (!isGlRequest) {
|
||||
kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT);
|
||||
}
|
||||
final BigDecimal amount = new BigDecimal(record.get("amount").toString());
|
||||
final Object ptId = record.get("payment_type_id");
|
||||
final Object txnDateObj = record.get("txn_date");
|
||||
@@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
final String description = (String) record.get("description");
|
||||
|
||||
String txnId = null;
|
||||
if (savingsAccountId != null) {
|
||||
if (isGlRequest) {
|
||||
// Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal.
|
||||
validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id")));
|
||||
txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description);
|
||||
} else if (savingsAccountId != null) {
|
||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no"));
|
||||
txnId = isCurrentAccount
|
||||
? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description)
|
||||
@@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
}
|
||||
|
||||
// Link voucher to the created savings txn and stamp voucher_no on it
|
||||
if (txnId != null) {
|
||||
if (txnId != null && !isGlRequest) {
|
||||
voucherService.linkSavingsTransaction(
|
||||
VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId));
|
||||
}
|
||||
|
||||
// The sides of the slip. A withdrawal is money leaving the member, so their account is
|
||||
// debited and the till that pays it out is credited.
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||
if (isGlRequest) {
|
||||
addGlWithdrawalVoucherLines(record, amount);
|
||||
} else {
|
||||
addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId);
|
||||
}
|
||||
|
||||
final String updatedLog = buildActivityLog(
|
||||
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null
|
||||
@@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
if (denomMap == null || denomMap.size() == 0) return;
|
||||
|
||||
final Object txnIdObj = record.get("txn_id");
|
||||
final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null;
|
||||
if (txnId == null) return; // no savings txn linked — cannot record denomination
|
||||
final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null;
|
||||
if (txnId == null) return; // no txn linked — cannot record denomination
|
||||
|
||||
final Object saObj = record.get("savings_account_id");
|
||||
final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null;
|
||||
@@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private static Long toLong(final Object value) {
|
||||
return value instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one
|
||||
* Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under
|
||||
* the id of its own journal entry in the cashier's position and closing report.
|
||||
*/
|
||||
private Long parseTxnKey(final String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Long.valueOf(raw);
|
||||
} catch (final NumberFormatException ignored) {
|
||||
try {
|
||||
final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw;
|
||||
return Long.parseUnsignedLong(hex, 16);
|
||||
} catch (final NumberFormatException ignored2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the
|
||||
* GL it is paid from must be one of the branch's mapped cash accounts. */
|
||||
private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) {
|
||||
if (glAccountId == null || contraGlId == null) {
|
||||
throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request");
|
||||
}
|
||||
final List<Long> cashGlIds = jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)",
|
||||
Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue());
|
||||
if (!cashGlIds.contains(contraGlId)) {
|
||||
throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL.");
|
||||
}
|
||||
if (cashGlIds.contains(glAccountId)) {
|
||||
throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is
|
||||
* switched off for manual postings). Returns the journal entry's transaction id. */
|
||||
private String executeGlWithdrawalJournal(final Map<?, ?> record, final BigDecimal amount,
|
||||
final Object paymentTypeId, final String txnDateStr, final String note) {
|
||||
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
|
||||
|
||||
final JsonObject debit = new JsonObject();
|
||||
debit.addProperty("glAccountId", toLong(record.get("gl_account_id")));
|
||||
debit.addProperty("amount", amount);
|
||||
final JsonObject credit = new JsonObject();
|
||||
credit.addProperty("glAccountId", toLong(record.get("contra_gl_id")));
|
||||
credit.addProperty("amount", amount);
|
||||
final JsonArray debits = new JsonArray();
|
||||
debits.add(debit);
|
||||
final JsonArray credits = new JsonArray();
|
||||
credits.add(credit);
|
||||
|
||||
final Object office = record.get("office_id");
|
||||
final Object currency = record.get("currency_code");
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L);
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR");
|
||||
if (note != null && !note.isBlank()) {
|
||||
payload.addProperty("comments", note);
|
||||
}
|
||||
if (paymentTypeId != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
|
||||
}
|
||||
payload.add("credits", credits);
|
||||
payload.add("debits", debits);
|
||||
|
||||
final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build();
|
||||
// The request raised its own voucher at submission; the journal command must not raise a second.
|
||||
final CommandProcessingResult result = voucherService
|
||||
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
|
||||
return result.getTransactionId();
|
||||
}
|
||||
|
||||
/** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */
|
||||
private void addGlWithdrawalVoucherLines(final Map<?, ?> record, final BigDecimal amount) {
|
||||
final String voucherNo = (String) record.get("voucher_no");
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final Long glId = toLong(record.get("gl_account_id"));
|
||||
final Long contraId = toLong(record.get("contra_gl_id"));
|
||||
final Map<String, Object> gl = glAccountForVoucher(glId);
|
||||
final Map<String, Object> contra = glAccountForVoucher(contraId);
|
||||
voucherService.addLines(voucherNo, List.of(
|
||||
VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount),
|
||||
VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount)));
|
||||
} catch (final RuntimeException e) {
|
||||
LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> glAccountForVoucher(final Long glAccountId) {
|
||||
if (glAccountId == null) {
|
||||
return Map.of();
|
||||
}
|
||||
return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst()
|
||||
.orElse(Map.of());
|
||||
}
|
||||
|
||||
private Map<?, ?> loadRecord(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT id, account_no, account_type, savings_account_id, client_id, client_name,
|
||||
@@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource {
|
||||
txn_date, status, clerk_id, clerk_name, clerk_username,
|
||||
manager_id, manager_name, manager_username, rejection_reason, txn_id,
|
||||
denomination_data, activity_log, submitted_at, actioned_at,
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no
|
||||
cashier_id, disbursed_by, disbursed_at, voucher_no,
|
||||
gl_account_id, contra_gl_id, office_id, currency_code
|
||||
FROM m_clerk_withdrawal_request WHERE id = ?
|
||||
""", id);
|
||||
if (rows.isEmpty()) {
|
||||
|
||||
+3
-2
@@ -679,8 +679,9 @@ 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).notNull().integerGreaterThanZero();
|
||||
.value(transfersInSuspenseAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
|
||||
final Long incomeFromInterestId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.INTEREST_ON_LOANS.getValue(), element);
|
||||
@@ -700,7 +701,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).notNull().integerGreaterThanZero();
|
||||
.value(incomeFromRecoveryAccountId).ignoreIfNull().integerGreaterThanZero();
|
||||
|
||||
final Long writeOffAccountId = this.fromApiJsonHelper
|
||||
.extractLongNamed(LoanProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), element);
|
||||
|
||||
+332
-2
@@ -9,11 +9,15 @@ 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;
|
||||
@@ -43,7 +47,8 @@ public class LoanProductAdvanceExtService {
|
||||
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
|
||||
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
|
||||
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"penalAppliedOn", "penalGraceDays", "npaTrigger", "smaStartAfterMonths", "stockStatementValidityDays", "memberRequired", "guarantorMustBeMember",
|
||||
"incomeMultiple", "minVintageMonths", "requiredDocuments", "interestAfterNpa", "undebitedInterestGlId",
|
||||
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
|
||||
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
|
||||
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
|
||||
@@ -76,7 +81,17 @@ public class LoanProductAdvanceExtService {
|
||||
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
|
||||
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
|
||||
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
|
||||
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
|
||||
"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" };
|
||||
|
||||
/**
|
||||
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
|
||||
@@ -90,10 +105,205 @@ 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);
|
||||
@@ -155,6 +365,14 @@ 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);
|
||||
@@ -163,6 +381,48 @@ 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);
|
||||
@@ -289,6 +549,20 @@ 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 ----------
|
||||
@@ -403,6 +677,13 @@ 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());
|
||||
@@ -411,6 +692,42 @@ 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());
|
||||
@@ -509,6 +826,19 @@ 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
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
+623
@@ -0,0 +1,623 @@
|
||||
/**
|
||||
* 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.voucher.api;
|
||||
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
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.NotFoundException;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.WebApplicationException;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
import java.math.BigDecimal;
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
||||
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
|
||||
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.cashreport.service.CashierRoleHelper;
|
||||
import org.apache.fineract.portfolio.client.service.KycRestrictionGuard;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherLine;
|
||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||
import org.apache.fineract.useradministration.domain.AppUser;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Corrects a cash deposit that went to the wrong savings account, without ever "undoing" it behind the
|
||||
* cashier's back.
|
||||
*
|
||||
* <ol>
|
||||
* <li>The cashier picks the day's voucher and asks to <b>unpass</b> it (UNPASS_PENDING). Nothing moves.</li>
|
||||
* <li>A manager approves the unpass (UNPASSED). Still nothing moves on any account: the voucher is simply
|
||||
* waiting for its correction entry.</li>
|
||||
* <li>The cashier opens the unpassed voucher, whose details are fixed, and changes only the account number
|
||||
* (CORRECTION_PENDING).</li>
|
||||
* <li>A manager approves the correction (COMPLETED). Only now do accounts change: the original deposit is
|
||||
* reversed on the wrong account and the same cash is deposited into the right one, under a new voucher; the
|
||||
* original voucher is revoked and the cashier's denominations follow the deposit.</li>
|
||||
* </ol>
|
||||
*
|
||||
* The balance of the wrong account is checked twice, when the cashier enters the correction and again when it is
|
||||
* approved, because the customer may have drawn the money in between.
|
||||
*/
|
||||
@Path("/v1/vouchers/corrections")
|
||||
@Component
|
||||
@Tag(name = "Voucher Corrections", description = "Unpass a wrongly-posted cash deposit voucher and re-post it to the right account.")
|
||||
@RequiredArgsConstructor
|
||||
public class VoucherCorrectionApiResource {
|
||||
|
||||
public static final String REF_VOUCHER_CORRECTION = "VOUCHER_CORRECTION";
|
||||
|
||||
private static final String UNPASS_PENDING = "UNPASS_PENDING";
|
||||
private static final String UNPASSED = "UNPASSED";
|
||||
private static final String UNPASS_REJECTED = "UNPASS_REJECTED";
|
||||
private static final String CORRECTION_PENDING = "CORRECTION_PENDING";
|
||||
private static final String COMPLETED = "COMPLETED";
|
||||
private static final String CLOSED = "CLOSED";
|
||||
private static final int SAVINGS_STATUS_ACTIVE = 300;
|
||||
private static final int DEPOSIT_TYPE_SAVINGS = 100;
|
||||
|
||||
private static final DateTimeFormatter VOUCHER_DAY = DateTimeFormatter.ofPattern("ddMMyyyy");
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
private final PlatformSecurityContext context;
|
||||
private final FromJsonHelper fromJsonHelper;
|
||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||
private final VoucherService voucherService;
|
||||
private final KycRestrictionGuard kycRestrictionGuard;
|
||||
private final CashierRoleHelper cashierRoleHelper;
|
||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||
|
||||
// ── Today's vouchers the cashier may unpass ──────────────────────────────────
|
||||
|
||||
/** Today's cash deposit vouchers on a savings account that have not been reversed, revoked or taken up already. */
|
||||
@GET
|
||||
@Path("eligible")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String eligible(@QueryParam("search") final String search) {
|
||||
requireUnpasser(this.context.authenticatedUser());
|
||||
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
|
||||
final List<Object> params = new ArrayList<>();
|
||||
params.add("%/" + day + "/%");
|
||||
final StringBuilder sql = new StringBuilder(ELIGIBLE_SQL);
|
||||
if (search != null && !search.isBlank()) {
|
||||
sql.append(" AND (v.voucher_no ILIKE ? OR sa.account_no ILIKE ? OR c.display_name ILIKE ?)");
|
||||
final String like = "%" + search.trim() + "%";
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
params.add(like);
|
||||
}
|
||||
sql.append(" ORDER BY v.id DESC");
|
||||
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
|
||||
}
|
||||
|
||||
private static final String ELIGIBLE_SQL = """
|
||||
SELECT v.voucher_no AS "voucherNo", v.amount, v.created_at AS "createdAt",
|
||||
v.created_by_username AS "cashier", v.savings_txn_id AS "txnId",
|
||||
t.savings_account_id AS "savingsAccountId", sa.account_no AS "accountNo",
|
||||
c.display_name AS "clientName", pt.value AS "paymentType"
|
||||
FROM m_voucher v
|
||||
JOIN m_savings_account_transaction t ON t.id = v.savings_txn_id
|
||||
JOIN m_savings_account sa ON sa.id = t.savings_account_id
|
||||
LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id
|
||||
JOIN m_payment_type pt ON pt.id = pd.payment_type_id
|
||||
WHERE v.voucher_type = 'DEPOSIT'
|
||||
AND COALESCE(v.reference_type, '') <> 'VOUCHER_CORRECTION'
|
||||
AND v.voucher_no LIKE ?
|
||||
AND t.is_reversed = false
|
||||
AND t.transaction_type_enum = 1
|
||||
AND pt.is_cash_payment = true
|
||||
AND sa.deposit_type_enum = 100
|
||||
AND NOT EXISTS (SELECT 1 FROM m_voucher_correction vc
|
||||
WHERE vc.original_voucher_no = v.voucher_no
|
||||
AND vc.status NOT IN ('UNPASS_REJECTED'))
|
||||
""";
|
||||
|
||||
// ── Step 1: cashier unpasses a voucher ───────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("unpass")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String unpass(final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
requireUnpasser(user);
|
||||
final JsonElement root = fromJsonHelper.parse(body);
|
||||
final String voucherNo = fromJsonHelper.extractStringNamed("voucherNo", root);
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", root);
|
||||
if (voucherNo == null || voucherNo.isBlank()) {
|
||||
throw apiError(400, "Voucher number is required");
|
||||
}
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Reason is required");
|
||||
}
|
||||
|
||||
final String day = DateUtils.getBusinessLocalDate().format(VOUCHER_DAY);
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(ELIGIBLE_SQL + " AND v.voucher_no = ?", "%/" + day + "/%",
|
||||
voucherNo.trim());
|
||||
if (rows.isEmpty()) {
|
||||
throw apiError(409, "Voucher " + voucherNo
|
||||
+ " cannot be unpassed: only today's cash deposit vouchers on a savings account that are still active qualify.");
|
||||
}
|
||||
final Map<String, Object> v = rows.get(0);
|
||||
|
||||
final Long id = jdbcTemplate.queryForObject("""
|
||||
INSERT INTO m_voucher_correction
|
||||
(original_voucher_no, original_txn_id, wrong_savings_account_id, wrong_account_no, wrong_client_name,
|
||||
amount, payment_type_id, txn_date, status, reason, unpass_by_id, unpass_by_name, unpass_at, activity_log)
|
||||
VALUES (?,?,?,?,?,?,(SELECT pd.payment_type_id FROM m_savings_account_transaction t
|
||||
JOIN m_payment_detail pd ON pd.id = t.payment_detail_id WHERE t.id = ?),
|
||||
(SELECT transaction_date FROM m_savings_account_transaction WHERE id = ?),
|
||||
?,?,?,?,?,?)
|
||||
RETURNING id
|
||||
""", Long.class, voucherNo.trim(), v.get("txnId"), v.get("savingsAccountId"), v.get("accountNo"), v.get("clientName"),
|
||||
v.get("amount"), v.get("txnId"), v.get("txnId"), UNPASS_PENDING, reason.trim(), user.getId(), user.getDisplayName(),
|
||||
Timestamp.from(Instant.now()), log(null, "UNPASS REQUESTED", user, reason.trim()));
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_PENDING));
|
||||
}
|
||||
|
||||
// ── Lists ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Corrections by status. {@code status} may be a comma-separated list. The approval page asks for
|
||||
* UNPASS_PENDING and CORRECTION_PENDING, the cashier's page for UNPASSED.
|
||||
*/
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String list(@QueryParam("status") final String status, @QueryParam("mine") @DefaultValue("false") final boolean mine) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
final StringBuilder sql = new StringBuilder("SELECT * FROM m_voucher_correction WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (status != null && !status.isBlank()) {
|
||||
final String[] statuses = status.split(",");
|
||||
sql.append(" AND status IN (").append("?,".repeat(statuses.length), 0, statuses.length * 2 - 1).append(")");
|
||||
for (final String s : statuses) {
|
||||
params.add(s.trim().toUpperCase());
|
||||
}
|
||||
}
|
||||
if (mine) {
|
||||
sql.append(" AND (unpass_by_id = ? OR correction_by_id = ?)");
|
||||
params.add(user.getId());
|
||||
params.add(user.getId());
|
||||
}
|
||||
sql.append(" ORDER BY id DESC LIMIT 200");
|
||||
return gson.toJson(jdbcTemplate.queryForList(sql.toString(), params.toArray()));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{id}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String get(@PathParam("id") final Long id) {
|
||||
this.context.authenticatedUser();
|
||||
return gson.toJson(load(id));
|
||||
}
|
||||
|
||||
// ── Step 2: manager approves / rejects the unpass ────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{id}/unpass/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approveUnpass(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASS_PENDING, "approved");
|
||||
requireNotMaker(user, r.get("unpass_by_id"), "unpass request");
|
||||
final int updated = jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, unpass_approved_by_id = ?, unpass_approved_by_name = ?,
|
||||
unpass_approved_at = ?, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", UNPASSED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
|
||||
log((String) r.get("activity_log"), "UNPASS APPROVED", user, null), id, UNPASS_PENDING);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This request was already actioned by another manager.");
|
||||
}
|
||||
// Deliberately no account movement here: the deposit stays exactly where it is until the correction is approved.
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/unpass/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String rejectUnpass(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASS_PENDING, "rejected");
|
||||
final String reason = requireReason(body);
|
||||
jdbcTemplate.update("UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ?", UNPASS_REJECTED,
|
||||
reason, log((String) r.get("activity_log"), "UNPASS REJECTED", user, reason), id);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASS_REJECTED));
|
||||
}
|
||||
|
||||
// ── Step 3: cashier enters the correct account ───────────────────────────────
|
||||
|
||||
/** Resolves the account number the cashier typed: name, balance and whether it can receive the deposit. */
|
||||
@GET
|
||||
@Path("account-lookup")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String accountLookup(@QueryParam("accountNo") final String accountNo) {
|
||||
this.context.authenticatedUser();
|
||||
final Map<String, Object> account = findSavingsAccount(accountNo);
|
||||
if (account == null) {
|
||||
throw apiError(404, "No savings account found for '" + accountNo + "'.");
|
||||
}
|
||||
return gson.toJson(account);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{id}/correction")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String submitCorrection(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
// The corrected entry is the cashier's own: a deposit is taken at the counter, so only a cashier can re-enter it.
|
||||
if (cashierRoleHelper.getCurrentCashierId() == null) {
|
||||
throw apiError(403, "Only a cashier can enter the correction for a voucher. Your login is not linked to a cashier.");
|
||||
}
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASSED, "corrected");
|
||||
final String accountNo = fromJsonHelper.extractStringNamed("correctAccountNo", fromJsonHelper.parse(body));
|
||||
final Map<String, Object> correct = validateCorrectAccount(r, accountNo);
|
||||
checkWrongAccountBalance(r);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, correct_savings_account_id = ?, correct_account_no = ?,
|
||||
correct_client_id = ?, correct_client_name = ?, correction_by_id = ?, correction_by_name = ?,
|
||||
correction_at = ?, rejection_reason = NULL, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", CORRECTION_PENDING, correct.get("id"), correct.get("accountNo"), correct.get("clientId"), correct.get("clientName"),
|
||||
user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()),
|
||||
log((String) r.get("activity_log"), "CORRECTION SUBMITTED", user, "to " + correct.get("accountNo")), id, UNPASSED);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", CORRECTION_PENDING));
|
||||
}
|
||||
|
||||
// ── Step 4: manager approves / rejects the correction ────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{id}/correction/approve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String approveCorrection(@PathParam("id") final Long id) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, CORRECTION_PENDING, "approved");
|
||||
requireNotMaker(user, r.get("correction_by_id"), "correction");
|
||||
|
||||
// Both sides are checked again: a lot can happen between the cashier saving and the manager approving.
|
||||
validateCorrectAccount(r, (String) r.get("correct_account_no"));
|
||||
checkWrongAccountBalance(r);
|
||||
|
||||
final Long wrongId = toLong(r.get("wrong_savings_account_id"));
|
||||
final Long correctId = toLong(r.get("correct_savings_account_id"));
|
||||
final Long origTxnId = toLong(r.get("original_txn_id"));
|
||||
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
|
||||
final String origVoucher = (String) r.get("original_voucher_no");
|
||||
|
||||
final Integer stillLive = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_savings_account_transaction WHERE id = ? AND savings_account_id = ? AND is_reversed = false",
|
||||
Integer.class, origTxnId, wrongId);
|
||||
if (stillLive == null || stillLive == 0) {
|
||||
throw apiError(409, "The original deposit has already been reversed; this voucher can no longer be corrected.");
|
||||
}
|
||||
|
||||
// 1. Take the deposit off the wrong account, 2. put the same cash into the right one.
|
||||
final JsonObject undoPayload = new JsonObject();
|
||||
voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(
|
||||
new CommandWrapperBuilder().undoSavingsAccountTransaction(wrongId, origTxnId).withJson(undoPayload.toString()).build()));
|
||||
|
||||
final String newTxnId = depositInto(correctId, amount, r);
|
||||
final Long newTxnKey = Long.valueOf(newTxnId);
|
||||
|
||||
// 3. A new voucher for the corrected entry, in the name of the cashier who made it, with the same till side.
|
||||
final String newVoucher = voucherService.createVoucher(VoucherService.TYPE_DEPOSIT, REF_VOUCHER_CORRECTION, id, amount, user);
|
||||
jdbcTemplate.update("UPDATE m_voucher SET created_by = ?, created_by_username = (SELECT username FROM m_appuser WHERE id = ?) WHERE voucher_no = ?",
|
||||
r.get("correction_by_id"), r.get("correction_by_id"), newVoucher);
|
||||
voucherService.linkSavingsTransaction(REF_VOUCHER_CORRECTION, id, newTxnKey);
|
||||
addNewVoucherLines(newVoucher, origVoucher, correctId, (String) r.get("correct_client_name"), (String) r.get("correct_account_no"),
|
||||
amount);
|
||||
|
||||
// 4. The original voucher is not touched. That it is revoked, and by which voucher, is recorded on this
|
||||
// correction (original_voucher_no / new_voucher_no) and read from there by the registers.
|
||||
|
||||
// 5. The notes the cashier took in follow the deposit to its new transaction and account.
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_savings_txn_denomination SET txn_id = ?, savings_account_id = ?, account_no = ?, client_id = ?
|
||||
WHERE txn_id = ? AND savings_account_id = ?
|
||||
""", newTxnKey, correctId, r.get("correct_account_no"), r.get("correct_client_id"), origTxnId, wrongId);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, correction_approved_by_id = ?, correction_approved_by_name = ?,
|
||||
correction_approved_at = ?, new_voucher_no = ?, new_txn_id = ?, activity_log = ?
|
||||
WHERE id = ? AND status = ?
|
||||
""", COMPLETED, user.getId(), user.getDisplayName(), Timestamp.from(Instant.now()), newVoucher, newTxnKey,
|
||||
log((String) r.get("activity_log"), "CORRECTION APPROVED", user, "new voucher " + newVoucher), id, CORRECTION_PENDING);
|
||||
|
||||
return gson.toJson(Map.of("resourceId", id, "status", COMPLETED, "newVoucherNo", newVoucher));
|
||||
}
|
||||
|
||||
/** A rejected correction goes back to the cashier as UNPASSED, to enter the account again. */
|
||||
@POST
|
||||
@Path("{id}/correction/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String rejectCorrection(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
validateCheckerRole(user);
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, CORRECTION_PENDING, "rejected");
|
||||
final String reason = requireReason(body);
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, correct_savings_account_id = NULL,
|
||||
correct_account_no = NULL, correct_client_id = NULL, correct_client_name = NULL, activity_log = ?
|
||||
WHERE id = ?
|
||||
""", UNPASSED, reason, log((String) r.get("activity_log"), "CORRECTION REJECTED", user, reason), id);
|
||||
return gson.toJson(Map.of("resourceId", id, "status", UNPASSED));
|
||||
}
|
||||
|
||||
/**
|
||||
* The cashier gives up the correction. Used when the wrong account no longer holds the money (the customer has
|
||||
* already drawn it), so it cannot be taken back: the cashier then puts the amount into the right account from
|
||||
* their own pocket as a fresh deposit, and this request is closed. No account moves here, and the original voucher
|
||||
* stays as it was.
|
||||
*/
|
||||
@POST
|
||||
@Path("{id}/close")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Transactional
|
||||
public String close(@PathParam("id") final Long id, final String body) {
|
||||
final AppUser user = this.context.authenticatedUser();
|
||||
if (cashierRoleHelper.getCurrentCashierId() == null) {
|
||||
throw apiError(403, "Only a cashier can close a voucher correction.");
|
||||
}
|
||||
final Map<String, Object> r = load(id);
|
||||
requireStatus(r, UNPASSED, "closed");
|
||||
final String reason = requireReason(body);
|
||||
final int updated = jdbcTemplate.update(
|
||||
"UPDATE m_voucher_correction SET status = ?, rejection_reason = ?, activity_log = ? WHERE id = ? AND status = ?", CLOSED,
|
||||
reason, log((String) r.get("activity_log"), "CLOSED BY CASHIER", user, reason), id, UNPASSED);
|
||||
if (updated == 0) {
|
||||
throw apiError(409, "This request was already actioned.");
|
||||
}
|
||||
return gson.toJson(Map.of("resourceId", id, "status", CLOSED));
|
||||
}
|
||||
|
||||
// ── For the passbook and transaction list of the wrong account ───────────────
|
||||
|
||||
/** Transactions of one account that were removed by a completed correction, so its screens can leave them out. */
|
||||
@GET
|
||||
@Path("voided-transactions")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
public String voidedTransactions(@QueryParam("savingsAccountId") final Long savingsAccountId) {
|
||||
this.context.authenticatedUser();
|
||||
final JsonArray ids = new JsonArray();
|
||||
jdbcTemplate.queryForList(
|
||||
"SELECT original_txn_id FROM m_voucher_correction WHERE status = ? AND wrong_savings_account_id = ?", Long.class, COMPLETED,
|
||||
savingsAccountId).forEach(ids::add);
|
||||
return gson.toJson(ids);
|
||||
}
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Unpassing a voucher is for the back office: an administrator or a clerk. The cashier does not unpass, only re-enters. */
|
||||
private void requireUnpasser(final AppUser user) {
|
||||
final boolean admin = user.hasAnyPermission("ALL_FUNCTIONS");
|
||||
final boolean clerk = user.getRoles().stream().anyMatch(role -> role.getName() != null && "clerk".equals(role.getName().toLowerCase().trim()));
|
||||
if (!admin && !clerk) {
|
||||
throw apiError(403, "Only an administrator or a clerk can unpass a voucher.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> validateCorrectAccount(final Map<String, Object> r, final String accountNo) {
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
throw apiError(400, "The correct account number is required");
|
||||
}
|
||||
final Map<String, Object> correct = findSavingsAccount(accountNo);
|
||||
if (correct == null) {
|
||||
throw apiError(404, "No savings account found for '" + accountNo + "'.");
|
||||
}
|
||||
if (toLong(correct.get("id")).equals(toLong(r.get("wrong_savings_account_id")))) {
|
||||
throw apiError(400, "The correct account is the same account the deposit was posted to.");
|
||||
}
|
||||
if (!Boolean.TRUE.equals(correct.get("canReceive"))) {
|
||||
throw apiError(409, "Account " + correct.get("accountNo") + " is not active, so it cannot receive this deposit.");
|
||||
}
|
||||
final Object wrongCurrency = jdbcTemplate.queryForList("SELECT currency_code FROM m_savings_account WHERE id = ?", String.class,
|
||||
r.get("wrong_savings_account_id")).stream().findFirst().orElse(null);
|
||||
if (wrongCurrency != null && !wrongCurrency.equals(correct.get("currencyCode"))) {
|
||||
throw apiError(409, "The correct account is in a different currency.");
|
||||
}
|
||||
kycRestrictionGuard.check(toLong(correct.get("id")), KycRestrictionGuard.Operation.CREDIT);
|
||||
return correct;
|
||||
}
|
||||
|
||||
/** The wrong account must still hold at least this deposit, or taking it back would overdraw it. */
|
||||
private void checkWrongAccountBalance(final Map<String, Object> r) {
|
||||
final BigDecimal balance = jdbcTemplate.queryForObject("SELECT account_balance_derived FROM m_savings_account WHERE id = ?",
|
||||
BigDecimal.class, r.get("wrong_savings_account_id"));
|
||||
final BigDecimal amount = new BigDecimal(r.get("amount").toString());
|
||||
if (balance == null || balance.compareTo(amount) < 0) {
|
||||
throw apiError(409, "Account " + r.get("wrong_account_no") + " no longer holds ₹" + amount + " (balance ₹"
|
||||
+ (balance == null ? "0" : balance) + "), so the deposit cannot be taken back. The customer may have used the money.");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> findSavingsAccount(final String accountNo) {
|
||||
if (accountNo == null || accountNo.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
|
||||
SELECT sa.id, sa.account_no AS "accountNo", sa.client_id AS "clientId", c.display_name AS "clientName",
|
||||
sa.account_balance_derived AS "balance", sa.currency_code AS "currencyCode",
|
||||
(sa.status_enum = ? AND sa.deposit_type_enum = ?) AS "canReceive"
|
||||
FROM m_savings_account sa LEFT JOIN m_client c ON c.id = sa.client_id
|
||||
WHERE sa.account_no = ?
|
||||
""", SAVINGS_STATUS_ACTIVE, DEPOSIT_TYPE_SAVINGS, accountNo.trim());
|
||||
return rows.isEmpty() ? null : rows.get(0);
|
||||
}
|
||||
|
||||
private String depositInto(final Long savingsAccountId, final BigDecimal amount, final Map<String, Object> r) {
|
||||
final Object txnDate = r.get("txn_date");
|
||||
final String date = txnDate == null ? DateUtils.getBusinessLocalDate().format(DateTimeFormatter.ofPattern("dd MMMM yyyy"))
|
||||
: LocalDate.parse(txnDate.toString().substring(0, 10)).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
|
||||
final JsonObject payload = new JsonObject();
|
||||
payload.addProperty("locale", "en");
|
||||
payload.addProperty("dateFormat", "dd MMMM yyyy");
|
||||
payload.addProperty("transactionDate", date);
|
||||
payload.addProperty("transactionAmount", amount);
|
||||
if (r.get("payment_type_id") != null) {
|
||||
payload.addProperty("paymentTypeId", ((Number) r.get("payment_type_id")).longValue());
|
||||
}
|
||||
payload.addProperty("note", "Correction of voucher " + r.get("original_voucher_no"));
|
||||
final CommandWrapper command = new CommandWrapperBuilder().savingsAccountDeposit(savingsAccountId).withJson(payload.toString())
|
||||
.build();
|
||||
final CommandProcessingResult result = voucherService.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(command));
|
||||
if (result.getResourceId() == null) {
|
||||
throw apiError(500, "The corrected deposit could not be posted.");
|
||||
}
|
||||
return result.getResourceId().toString();
|
||||
}
|
||||
|
||||
/** The new voucher keeps the original's till side and names the right account on the credit side. */
|
||||
private void addNewVoucherLines(final String newVoucher, final String origVoucher, final Long correctId, final String clientName,
|
||||
final String accountNo, final BigDecimal amount) {
|
||||
try {
|
||||
final List<VoucherLine> lines = new ArrayList<>();
|
||||
for (final Map<String, Object> l : jdbcTemplate.queryForList(
|
||||
"SELECT gl_account_id, party_name, account_no FROM m_voucher_line WHERE voucher_no = ? AND entry_type = 'DR' ORDER BY line_no",
|
||||
origVoucher)) {
|
||||
lines.add(VoucherLine.debitGl(toLong(l.get("gl_account_id")), (String) l.get("party_name"), (String) l.get("account_no"),
|
||||
amount));
|
||||
}
|
||||
if (lines.isEmpty()) {
|
||||
lines.add(VoucherLine.debitGl(null, "Cash in Hand", null, amount));
|
||||
}
|
||||
lines.add(VoucherLine.creditSavings(correctId, clientName, accountNo, amount));
|
||||
voucherService.addLines(newVoucher, lines);
|
||||
} catch (final RuntimeException e) {
|
||||
// The deposit is already posted; a voucher without its sides is better than undoing a correct posting.
|
||||
org.slf4j.LoggerFactory.getLogger(VoucherCorrectionApiResource.class)
|
||||
.error("Could not record the sides of correction voucher {}", newVoucher, e);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> load(final Long id) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT * FROM m_voucher_correction WHERE id = ?", id);
|
||||
if (rows.isEmpty()) {
|
||||
throw new NotFoundException("Voucher correction not found: " + id);
|
||||
}
|
||||
return rows.get(0);
|
||||
}
|
||||
|
||||
private void requireStatus(final Map<String, Object> r, final String expected, final String action) {
|
||||
if (!expected.equals(r.get("status"))) {
|
||||
throw apiError(409, "Only " + expected + " requests can be " + action + ". Current status: " + r.get("status"));
|
||||
}
|
||||
}
|
||||
|
||||
private void requireNotMaker(final AppUser user, final Object makerId, final String what) {
|
||||
if (makerId != null && toLong(makerId).equals(user.getId())) {
|
||||
throw apiError(403, "Maker-checker: you cannot approve a " + what + " you raised yourself. Ask another manager.");
|
||||
}
|
||||
}
|
||||
|
||||
private String requireReason(final String body) {
|
||||
final String reason = fromJsonHelper.extractStringNamed("reason", fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"));
|
||||
if (reason == null || reason.isBlank()) {
|
||||
throw apiError(400, "Reason is required");
|
||||
}
|
||||
return reason.trim();
|
||||
}
|
||||
|
||||
/** Checker authorization, the same rule the withdrawal and deposit request registers use. */
|
||||
private void validateCheckerRole(final AppUser user) {
|
||||
if (user.hasAnyPermission("ALL_FUNCTIONS")) {
|
||||
return;
|
||||
}
|
||||
final boolean isChecker = user.getRoles().stream().map(role -> role.getName() == null ? "" : role.getName().toLowerCase().trim())
|
||||
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
|
||||
if (!isChecker) {
|
||||
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject voucher corrections.");
|
||||
}
|
||||
}
|
||||
|
||||
private static Long toLong(final Object value) {
|
||||
return value instanceof Number n ? n.longValue() : null;
|
||||
}
|
||||
|
||||
private String log(final String existing, final String action, final AppUser user, final String note) {
|
||||
final JsonArray entries = existing == null || existing.isBlank() ? new JsonArray() : gson.fromJson(existing, JsonArray.class);
|
||||
final JsonObject e = new JsonObject();
|
||||
e.addProperty("action", action);
|
||||
e.addProperty("by", user.getUsername());
|
||||
e.addProperty("at", Instant.now().toString());
|
||||
if (note != null) {
|
||||
e.addProperty("note", note);
|
||||
}
|
||||
entries.add(e);
|
||||
return gson.toJson(entries);
|
||||
}
|
||||
|
||||
private WebApplicationException apiError(final int httpStatus, final String message) {
|
||||
final JsonObject body = new JsonObject();
|
||||
body.addProperty("message", message);
|
||||
body.addProperty("defaultUserMessage", message);
|
||||
body.addProperty("httpStatus", httpStatus);
|
||||
final JsonArray errors = new JsonArray();
|
||||
final JsonObject err = new JsonObject();
|
||||
err.addProperty("defaultUserMessage", message);
|
||||
errors.add(err);
|
||||
body.add("errors", errors);
|
||||
return new WebApplicationException(Response.status(httpStatus).entity(gson.toJson(body)).type(MediaType.APPLICATION_JSON).build());
|
||||
}
|
||||
}
|
||||
+24
-2
@@ -628,9 +628,15 @@ public class VoucherVerificationApiResource {
|
||||
|
||||
BigDecimal total = BigDecimal.ZERO;
|
||||
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||
final Map<String, Map<String, Object>> marks = voucherService
|
||||
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
if (amount != null) {
|
||||
final Map<String, Object> mark = marks.get((String) r.get("voucher_no"));
|
||||
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
|
||||
// A revoked voucher stays on the register, marked, but its money is on the voucher that replaced it, so it
|
||||
// is left out of the total rather than counted twice.
|
||||
if (amount != null && !revoked) {
|
||||
total = total.add(amount);
|
||||
}
|
||||
|
||||
@@ -638,6 +644,7 @@ public class VoucherVerificationApiResource {
|
||||
m.put("voucherNo", r.get("voucher_no"));
|
||||
m.put("voucherType", r.get("voucher_type"));
|
||||
m.put("amount", amount);
|
||||
putVoucherMark(m, mark);
|
||||
m.put("createdAt", r.get("created_at") == null ? null : r.get("created_at").toString());
|
||||
// The day the posting belongs to, and whether that differs from the day it was written.
|
||||
// A nightly run that finishes after midnight writes tomorrow's voucher for yesterday's
|
||||
@@ -847,6 +854,8 @@ public class VoucherVerificationApiResource {
|
||||
int raisedCount = 0;
|
||||
int verifiedCount = 0;
|
||||
final List<Map<String, Object>> out = new java.util.ArrayList<>();
|
||||
final Map<String, Map<String, Object>> myWorkMarks = voucherService
|
||||
.voucherMarks(rows.stream().map(x -> (String) x.get("voucher_no")).toList());
|
||||
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final BigDecimal amount = (BigDecimal) r.get("amount");
|
||||
@@ -869,7 +878,10 @@ public class VoucherVerificationApiResource {
|
||||
final String side = ("CR".equals(rawSide) || "DR".equals(rawSide)) && lineCount > 0 && !tillTouched
|
||||
? "JV" : rawSide;
|
||||
|
||||
if (amount != null) {
|
||||
final Map<String, Object> mark = myWorkMarks.get(voucherNo);
|
||||
final boolean revoked = mark != null && "REVOKED".equals(mark.get("voucherStatus"));
|
||||
// A revoked voucher is shown, marked, but its money is on the voucher that replaced it, so it is not counted.
|
||||
if (amount != null && !revoked) {
|
||||
if ("CR".equals(side)) {
|
||||
receivedTotal = receivedTotal.add(amount);
|
||||
} else if ("DR".equals(side)) {
|
||||
@@ -897,6 +909,7 @@ public class VoucherVerificationApiResource {
|
||||
m.put("raisedBy", r.get("created_by_username"));
|
||||
m.put("branchName", r.get("branch_name"));
|
||||
m.put("verificationStatus", r.get("verification_status"));
|
||||
putVoucherMark(m, mark);
|
||||
m.put("verifiedByName", r.get("verified_by_name"));
|
||||
m.put("verifiedAt", r.get("verified_at") == null ? null : r.get("verified_at").toString());
|
||||
m.put("debitParty", r.get("debit_party"));
|
||||
@@ -968,6 +981,14 @@ public class VoucherVerificationApiResource {
|
||||
* returns an empty list rather than an error: the voucher is real, its posting simply was not
|
||||
* captured at the time, and reporting it as missing would be wrong.
|
||||
*/
|
||||
/** Adds where the voucher stands (active, revoked, under correction, corrected) to a register row. */
|
||||
private void putVoucherMark(final Map<String, Object> row, final Map<String, Object> mark) {
|
||||
row.put("voucherStatus", mark == null ? "ACTIVE" : mark.get("voucherStatus"));
|
||||
row.put("revokedByVoucherNo", mark == null ? null : mark.get("revokedByVoucherNo"));
|
||||
row.put("replacesVoucherNo", mark == null ? null : mark.get("replacesVoucherNo"));
|
||||
row.put("correctionNote", mark == null ? null : mark.get("correctionNote"));
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("detail")
|
||||
public String detail(@QueryParam("voucherNo") final String voucherNo) {
|
||||
@@ -1032,6 +1053,7 @@ public class VoucherVerificationApiResource {
|
||||
out.put("createdBy", h.get("created_by_username"));
|
||||
out.put("createdAt", h.get("created_at") == null ? null : h.get("created_at").toString());
|
||||
out.put("verificationStatus", h.get("verification_status"));
|
||||
putVoucherMark(out, voucherService.voucherMarks(List.of(voucherNo)).get(voucherNo));
|
||||
out.put("lines", lines);
|
||||
out.put("totalDebit", totalDebit);
|
||||
out.put("totalCredit", totalCredit);
|
||||
|
||||
+60
@@ -595,6 +595,66 @@ public class VoucherService {
|
||||
""", voucherNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where each of these vouchers stands, for the registers that list vouchers.
|
||||
*
|
||||
* A voucher is ACTIVE until a correction touches it. REVOKED means its deposit was moved to another account and a
|
||||
* new voucher replaced it (revokedByVoucherNo). A voucher with a correction under way reads UNPASS_PENDING,
|
||||
* UNPASSED or CORRECTION_PENDING. The replacement itself reads CORRECTED, with the voucher it replaced in
|
||||
* replacesVoucherNo. A register adds these to its rows and leaves a REVOKED voucher out of its totals, since its
|
||||
* money now sits on the replacement.
|
||||
*/
|
||||
public Map<String, Map<String, Object>> voucherMarks(final java.util.Collection<String> voucherNos) {
|
||||
final Map<String, Map<String, Object>> marks = new java.util.HashMap<>();
|
||||
final List<String> nos = voucherNos == null ? List.of()
|
||||
: voucherNos.stream().filter(n -> n != null && !n.isBlank()).distinct().toList();
|
||||
if (nos.isEmpty()) {
|
||||
return marks;
|
||||
}
|
||||
final String in = String.join(",", java.util.Collections.nCopies(nos.size(), "?"));
|
||||
// Read from the correction register alone: the voucher itself carries no status, so nothing about a correction
|
||||
// is ever written onto m_voucher and the voucher rows stay exactly as they were raised.
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
|
||||
"SELECT v.voucher_no,"
|
||||
+ " (SELECT vc.new_voucher_no FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS revoked_by_voucher_no,"
|
||||
+ " (SELECT vc.original_voucher_no FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.new_voucher_no = v.voucher_no AND vc.status = 'COMPLETED'"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS replaces_voucher_no,"
|
||||
+ " (SELECT vc.status FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no"
|
||||
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED')"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_status,"
|
||||
// The reason behind it: why a closed request was closed, otherwise why the voucher was unpassed.
|
||||
+ " (SELECT CASE WHEN vc.status = 'CLOSED' THEN vc.rejection_reason ELSE vc.reason END"
|
||||
+ " FROM m_voucher_correction vc"
|
||||
+ " WHERE vc.original_voucher_no = v.voucher_no"
|
||||
+ " AND vc.status IN ('UNPASS_PENDING', 'UNPASSED', 'CORRECTION_PENDING', 'CLOSED', 'COMPLETED')"
|
||||
+ " ORDER BY vc.id DESC LIMIT 1) AS correction_note"
|
||||
+ " FROM m_voucher v WHERE v.voucher_no IN (" + in + ")",
|
||||
nos.toArray());
|
||||
for (final Map<String, Object> r : rows) {
|
||||
final String status;
|
||||
if (r.get("revoked_by_voucher_no") != null) {
|
||||
status = "REVOKED";
|
||||
} else if (r.get("correction_status") != null) {
|
||||
status = (String) r.get("correction_status");
|
||||
} else if (r.get("replaces_voucher_no") != null) {
|
||||
status = "CORRECTED";
|
||||
} else {
|
||||
status = "ACTIVE";
|
||||
}
|
||||
final Map<String, Object> m = new java.util.LinkedHashMap<>();
|
||||
m.put("voucherStatus", status);
|
||||
m.put("revokedByVoucherNo", r.get("revoked_by_voucher_no"));
|
||||
m.put("replacesVoucherNo", r.get("replaces_voucher_no"));
|
||||
m.put("correctionNote", r.get("correction_note"));
|
||||
marks.put((String) r.get("voucher_no"), m);
|
||||
}
|
||||
return marks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Looks up the voucher for the given request and returns its voucher_no,
|
||||
* or null if none exists (e.g. records created before vouchers existed).
|
||||
|
||||
@@ -623,6 +623,17 @@
|
||||
<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"/>
|
||||
|
||||
<!-- 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. -->
|
||||
|
||||
@@ -174,4 +174,8 @@
|
||||
<include file="parts/0598_recurring_deposit_extend_tenure_permission.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0593_fd_interest_posting_approval.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0599_cash_exchange_customer_pan.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0600_clerk_withdrawal_request_gl.xml" relativeToChangelogFile="true"/>
|
||||
<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"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?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>
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
<?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: 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"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
<?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>
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) goes through the same request, manager approval and
|
||||
cashier disbursement as a savings withdrawal, so the request carries the GL account and the cash GL it is paid
|
||||
from. All four columns are nullable: savings, current and deposit requests do not use them.
|
||||
-->
|
||||
<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="0600_clerk_withdrawal_request_add_gl_columns" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<tableExists tableName="m_clerk_withdrawal_request"/>
|
||||
<not><columnExists tableName="m_clerk_withdrawal_request" columnName="gl_account_id"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_clerk_withdrawal_request">
|
||||
<column name="gl_account_id" type="BIGINT"/>
|
||||
<column name="contra_gl_id" type="BIGINT"/>
|
||||
<column name="office_id" type="BIGINT"/>
|
||||
<column name="currency_code" type="VARCHAR(10)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
<?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
@@ -0,0 +1,49 @@
|
||||
<?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>
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
Voucher correction: a cash deposit posted to the wrong savings account is unpassed, approved, re-entered against
|
||||
the right account by the cashier and approved again. m_voucher_correction carries that request through its steps;
|
||||
m_voucher gains a status so the original voucher can be REVOKED once its replacement exists.
|
||||
-->
|
||||
<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="0601_voucher_add_status" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><columnExists tableName="m_voucher" columnName="status"/></not>
|
||||
</preConditions>
|
||||
<addColumn tableName="m_voucher">
|
||||
<column name="status" type="VARCHAR(20)" defaultValue="ACTIVE">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="revoked_at" type="TIMESTAMP"/>
|
||||
<column name="revoked_by_voucher_no" type="VARCHAR(30)"/>
|
||||
</addColumn>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0601_create_voucher_correction" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_voucher_correction"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_voucher_correction">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="original_voucher_no" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="original_txn_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="wrong_savings_account_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="wrong_account_no" type="VARCHAR(50)"/>
|
||||
<column name="wrong_client_name" type="VARCHAR(255)"/>
|
||||
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
|
||||
<column name="payment_type_id" type="BIGINT"/>
|
||||
<column name="txn_date" type="DATE"/>
|
||||
<column name="status" type="VARCHAR(30)"><constraints nullable="false"/></column>
|
||||
<column name="reason" type="VARCHAR(1000)"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="unpass_by_id" type="BIGINT"/>
|
||||
<column name="unpass_by_name" type="VARCHAR(255)"/>
|
||||
<column name="unpass_at" type="TIMESTAMP"/>
|
||||
<column name="unpass_approved_by_id" type="BIGINT"/>
|
||||
<column name="unpass_approved_by_name" type="VARCHAR(255)"/>
|
||||
<column name="unpass_approved_at" type="TIMESTAMP"/>
|
||||
<column name="correct_savings_account_id" type="BIGINT"/>
|
||||
<column name="correct_account_no" type="VARCHAR(50)"/>
|
||||
<column name="correct_client_id" type="BIGINT"/>
|
||||
<column name="correct_client_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_by_id" type="BIGINT"/>
|
||||
<column name="correction_by_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_at" type="TIMESTAMP"/>
|
||||
<column name="correction_approved_by_id" type="BIGINT"/>
|
||||
<column name="correction_approved_by_name" type="VARCHAR(255)"/>
|
||||
<column name="correction_approved_at" type="TIMESTAMP"/>
|
||||
<column name="new_voucher_no" type="VARCHAR(30)"/>
|
||||
<column name="new_txn_id" type="BIGINT"/>
|
||||
<column name="activity_log" type="TEXT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_voucher_correction" indexName="idx_voucher_correction_voucher">
|
||||
<column name="original_voucher_no"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
The voucher itself carries no correction status: whether a voucher was unpassed, revoked or closed is read from
|
||||
m_voucher_correction. 0601 had added status, revoked_at and revoked_by_voucher_no to m_voucher; nothing writes them
|
||||
any more, so they are dropped and the voucher table is back to exactly what the registers raised.
|
||||
-->
|
||||
<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="0602_voucher_drop_status_columns" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<columnExists tableName="m_voucher" columnName="status"/>
|
||||
</preConditions>
|
||||
<dropColumn tableName="m_voucher" columnName="status"/>
|
||||
<dropColumn tableName="m_voucher" columnName="revoked_at"/>
|
||||
<dropColumn tableName="m_voucher" columnName="revoked_by_voucher_no"/>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Licensed to the Apache Software Foundation (ASF) under one
|
||||
or more contributor license agreements.
|
||||
-->
|
||||
<!--
|
||||
Cash Transfer is maker-checker: the person who allocates cash from the vault to a cashier raises a request, and the
|
||||
allocation is posted only when a different person approves it. m_cash_transfer stays the table of posted transfers;
|
||||
this table holds the requests, with the whole request body so the approval posts exactly what was asked for.
|
||||
-->
|
||||
<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="0603_create_cash_transfer_request" author="cbsnexora">
|
||||
<preConditions onFail="MARK_RAN">
|
||||
<not><tableExists tableName="m_cash_transfer_request"/></not>
|
||||
</preConditions>
|
||||
<createTable tableName="m_cash_transfer_request">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="transfer_date" type="DATE"/>
|
||||
<column name="from_teller_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="to_cashier_id" type="BIGINT"><constraints nullable="false"/></column>
|
||||
<column name="from_party" type="VARCHAR(255)"/>
|
||||
<column name="to_party" type="VARCHAR(255)"/>
|
||||
<column name="note" type="VARCHAR(1000)"/>
|
||||
<column name="total_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
|
||||
<column name="request_body" type="TEXT"><constraints nullable="false"/></column>
|
||||
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
|
||||
<column name="maker_id" type="BIGINT"/>
|
||||
<column name="maker_name" type="VARCHAR(255)"/>
|
||||
<column name="maker_at" type="TIMESTAMP"/>
|
||||
<column name="checker_id" type="BIGINT"/>
|
||||
<column name="checker_name" type="VARCHAR(255)"/>
|
||||
<column name="checker_at" type="TIMESTAMP"/>
|
||||
<column name="rejection_reason" type="VARCHAR(1000)"/>
|
||||
<column name="cash_transfer_id" type="BIGINT"/>
|
||||
</createTable>
|
||||
<createIndex tableName="m_cash_transfer_request" indexName="idx_cash_transfer_request_status">
|
||||
<column name="status"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
</databaseChangeLog>
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
<?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>
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
<?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>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
<?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>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
<?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>
|
||||
+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));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
|
||||
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));
|
||||
when(loanAccountData.getDelinquencyRange()).thenReturn(new DelinquencyRangeData(1L, "classification", 1, 10, null));
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
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);
|
||||
DelinquencyRange range1 = DelinquencyRange.instance("Range1", 1, 2, null);
|
||||
range1.setId(1L);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30);
|
||||
DelinquencyRange range2 = DelinquencyRange.instance("Range30", 3, 30, null);
|
||||
range2.setId(2L);
|
||||
List<DelinquencyRange> listDelinquencyRanges = Arrays.asList(range1, range2);
|
||||
DelinquencyBucket delinquencyBucket = new DelinquencyBucket("test Bucket");
|
||||
|
||||
Reference in New Issue
Block a user