Merge latest feature/advanceLoanProduct into feature/merge-dds-advance-loan-into-dev-latest
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s

Brings Sonali's four new commits (7-12 Oct):
- Loans A/C extension fields (collateral, eligibility, repayment, rate
  source, prepayment)
- Maker-checker for Delinquency Ranges and Buckets
- Loan interest rate register product master and GL account seeding
- Her merge of dev-latest (7 Oct)

Conflict resolved:
- changelog-tenant.xml: kept both. 0594_loan_rate_register_product_
  master, 0595_seed_loan_rate_register_gl_accounts,
  0596_enable_delinquency_maker_checker and 0597_add_loan_product_
  loans_ext_fields go with the earlier advance loan changelogs, before
  the DDS block that stays last. No file name is shared with
  dev-latest's 0594-0597 files.

0581_enable_loan_rate_register_maker_checker.xml: taken as on
feature/advanceLoanProduct. It edits the existing 0581a/0581b
changesets (CBSLOANBASEINTERESTRATE -> LOANBASEINTERESTRATE) and adds
0581c to rename rows already seeded; all three carry
<validCheckSum>ANY</validCheckSum>, so databases that ran the old
version still validate.

CommandSourceService: Sonali's LOANBASEINTERESTRATE, DELINQUENCY_BUCKET
and DELINQUENCY_RANGE checks sit alongside the DDS isDdsEntity check.

Nothing from dev-latest, DDS or Sonali is removed; no file deleted; no
DB password change. Verified with :fineract-provider:compileJava.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 16:29:42 +05:30
co-authored by Claude Opus 5.5
22 changed files with 1172 additions and 30 deletions
@@ -157,6 +157,9 @@ public class CommandSourceService {
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_BUCKET".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_RANGE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
@@ -2460,9 +2460,9 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsLoanBaseInterestRate() {
public CommandWrapperBuilder createLoanBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSLOANBASEINTERESTRATE";
this.entityName = "LOANBASEINTERESTRATE";
this.entityId = null;
this.href = "/loanproducts/baseinterestrates";
return this;
@@ -295,6 +295,175 @@ public class LoanProductAdvanceExt {
@Column(name = "cheque_block_lien_released")
private Boolean chequeBlockLienReleased;
// ---- Loans A/C (EMI) wizard fields -------------------------------------
@Column(name = "loan_security_mandatory")
private String securityMandatory;
@Column(name = "loan_guarantors_required")
private Integer guarantorsRequired;
@Column(name = "loan_co_applicant")
private String coApplicant;
@Column(name = "loan_third_party_security")
private String thirdPartySecurity;
@Column(name = "loan_legal_opinion_required")
private Boolean legalOpinionRequired;
@Column(name = "loan_property_insurance_required")
private Boolean propertyInsuranceRequired;
@Column(name = "loan_register_charge")
private Boolean registerCharge;
@Column(name = "loan_release_security_on_closure")
private Boolean releaseSecurityOnClosure;
@Column(name = "loan_allow_multiple_securities")
private Boolean allowMultipleSecurities;
@Column(name = "loan_allow_third_party_deposit")
private Boolean allowThirdPartyDeposit;
@Column(name = "loan_min_age")
private Integer minAge;
@Column(name = "loan_max_age_at_maturity")
private Integer maxAgeAtMaturity;
@Column(name = "loan_min_cibil")
private Integer minCibil;
@Column(name = "loan_min_monthly_income")
private BigDecimal minMonthlyIncome;
@Column(name = "loan_max_foir")
private BigDecimal maxFoir;
@Column(name = "loan_max_loans_per_customer")
private Integer maxLoansPerCustomer;
@Column(name = "loan_disbursement_type")
private String disbursementType;
@Column(name = "loan_disbursement_mode")
private String disbursementMode;
@Column(name = "loan_undisbursed_interest")
private String undisbursedInterest;
@Column(name = "loan_pre_emi_interest")
private Boolean preEmiInterest;
@Column(name = "loan_emi_due_day")
private String emiDueDay;
@Column(name = "loan_holiday_rule")
private String holidayRule;
@Column(name = "loan_moratorium_months")
private Integer moratoriumMonths;
@Column(name = "loan_moratorium_interest")
private String moratoriumInterest;
@Column(name = "loan_variable_installments")
private Boolean variableInstallments;
@Column(name = "loan_repayment_mode")
private String repaymentMode;
@Column(name = "loan_linked_account_type")
private String linkedAccountType;
@Column(name = "loan_pdc_collection")
private String pdcCollection;
@Column(name = "loan_mandate_required")
private Boolean mandateRequired;
@Column(name = "loan_advance_emi")
private String advanceEmi;
@Column(name = "loan_rate_type")
private String rateType;
@Column(name = "loan_register_particular")
private String registerParticular;
@Column(name = "loan_apply_cibil_adjustment")
private Boolean applyCibilAdjustment;
@Column(name = "loan_rate_resolved_at")
private String rateResolvedAt;
@Column(name = "loan_calc_period")
private String calcPeriod;
@Column(name = "loan_interest_starts_from")
private String interestStartsFrom;
@Column(name = "loan_compounding")
private String compounding;
@Column(name = "loan_benchmark")
private String benchmark;
@Column(name = "loan_reset_frequency")
private String resetFrequency;
@Column(name = "loan_reset_change")
private String resetChange;
@Column(name = "loan_part_prepayment_allowed")
private Boolean partPrepaymentAllowed;
@Column(name = "loan_min_part_payment")
private BigDecimal minPartPayment;
@Column(name = "loan_allowed_on")
private String allowedOn;
@Column(name = "loan_part_payments_per_year")
private String partPaymentsPerYear;
@Column(name = "loan_part_prepayment_mode")
private String partPrepaymentMode;
@Column(name = "loan_foreclosure_floating_individual")
private BigDecimal foreclosureFloatingIndividual;
@Column(name = "loan_foreclosure_fixed_individual")
private BigDecimal foreclosureFixedIndividual;
@Column(name = "loan_foreclosure_non_individual")
private BigDecimal foreclosureNonIndividual;
@Column(name = "loan_foreclosure_takeover")
private BigDecimal foreclosureTakeover;
@Column(name = "loan_foreclosure_quote_valid_days")
private Integer foreclosureQuoteValidDays;
@Column(name = "loan_auto_close_on_zero_balance")
private Boolean autoCloseOnZeroBalance;
@Column(name = "loan_noc_letter")
private String nocLetter;
@Column(name = "loan_restructuring")
private String restructuring;
@Column(name = "loan_release_collateral_on_closure")
private Boolean releaseCollateralOnClosure;
@Column(name = "loan_top_up_allowed")
private Boolean topUpAllowed;
@Column(name = "loan_balance_transfer_out_allowed")
private Boolean balanceTransferOutAllowed;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
@@ -0,0 +1,52 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a
* property of the particular, so it lives here rather than on each rate-register version. Kept as a
* plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity
* graph into the loan module.
*/
@Entity
@Table(name = "m_loan_rate_register_product")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "sr_no", nullable = false)
private Integer srNo;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "created_date")
private LocalDateTime createdDate;
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterProductRepository extends JpaRepository<LoanRateRegisterProduct, Long> {
/** Active particulars in chart order. */
List<LoanRateRegisterProduct> findByIsActiveTrueOrderBySrNoAsc();
}
@@ -41,6 +41,10 @@ public class LoanRateRegisterSlabHistory {
private Long effectiveId;
// ---- product dimension ----------------------------------------------
/** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */
@Column(name = "product_id")
private Long productId;
@Column(name = "product_sr_no", nullable = false)
private Integer productSrNo;
@@ -73,11 +77,12 @@ public class LoanRateRegisterSlabHistory {
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Integer productSrNo, final String productName,
final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto, final BigDecimal rateValue,
final String rateFormula, final boolean isFormula, final boolean isNa) {
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo,
final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto,
final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) {
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
s.effectiveId = effectiveId;
s.productId = productId;
s.productSrNo = productSrNo;
s.productName = productName;
s.slabOrder = slabOrder;
@@ -37,10 +37,15 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -58,6 +63,7 @@ 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.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.ApiFacingEnum;
@@ -222,11 +228,54 @@ public class LoanProductsApiResource {
return this.productMixDataApiJsonSerializer.serialize(settings, productMixes, PRODUCT_MIX_DATA_PARAMETERS);
}
final Collection<LoanProductData> products = this.loanProductReadPlatformService.retrieveAllLoanProducts();
final Collection<LoanProductData> products = enrichAndFilterProducts(
this.loanProductReadPlatformService.retrieveAllLoanProducts(), uriInfo);
return this.toApiJsonSerializer.serialize(settings, products, LOAN_PRODUCT_DATA_PARAMETERS);
}
/**
* Adds each product's category and type ("Advances A/C" or "Loans A/C" and its sub type) to the list, and applies
* the optional filters {@code activeOnly}, {@code productTypeGroup} and {@code productTypeSubGroup}. With none of
* them given the list holds the same products as before, each now carrying its classification.
*/
private Collection<LoanProductData> enrichAndFilterProducts(final Collection<LoanProductData> products, final UriInfo uriInfo) {
final MultivaluedMap<String, String> query = uriInfo.getQueryParameters();
final boolean activeOnly = Boolean.parseBoolean(query.getFirst("activeOnly"));
final String group = trimToNull(query.getFirst("productTypeGroup"));
final String subGroup = trimToNull(query.getFirst("productTypeSubGroup"));
final Map<Long, Map<String, Object>> extensions = this.loanProductAdvanceExtService.retrieveAllAsMap();
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<LoanProductData> result = new ArrayList<>();
for (final LoanProductData product : products) {
final Map<String, Object> ext = extensions.getOrDefault(product.getId(), Collections.emptyMap());
if (activeOnly && !isActiveOn(product, today)) {
continue;
}
if (group != null && !group.equalsIgnoreCase(String.valueOf(ext.get("productTypeGroup")))) {
continue;
}
if (subGroup != null && !subGroup.equalsIgnoreCase(String.valueOf(ext.get("productTypeSubGroup")))) {
continue;
}
product.setAdvanceExtensions(new LinkedHashMap<>(ext));
result.add(product);
}
return result;
}
/** A product is active from its start date until its close date; either end may be open. */
private static boolean isActiveOn(final LoanProductData product, final LocalDate date) {
final LocalDate start = product.getStartDate();
final LocalDate close = product.getCloseDate();
return (start == null || !start.isAfter(date)) && (close == null || !close.isBefore(date));
}
private static String trimToNull(final String value) {
return value == null || value.isBlank() ? null : value.trim();
}
@GET
@Path("template")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -44,7 +44,7 @@ import org.springframework.stereotype.Component;
* a POST that goes through Fineract's maker-checker pipeline.
*
* Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded
* permissions {@code CREATE_CBSLOANBASEINTERESTRATE} (maker) and {@code CREATE_CBSLOANBASEINTERESTRATE_CHECKER}.
* permissions {@code CREATE_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}.
*/
@Path("/v1/loanproducts/baseinterestrates")
@Component
@@ -76,6 +76,18 @@ public class LoanProductsBaseInterestRatesApiResource {
return this.gson.toJson(data);
}
/** Master list of particulars (loan types) that make up the chart rows. Literal path wins over {id}. */
@GET
@Path("products")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get Loan Interest Rate Register particulars", description = "Returns the master list of loan types (chart rows) with their mapped GL account id, in chart order.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveProducts() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.gson.toJson(this.readService.retrieveProducts());
}
/** The currently in-force version, with its full chart + CIBIL. */
@GET
@Path("active")
@@ -103,7 +115,7 @@ public class LoanProductsBaseInterestRatesApiResource {
}
/**
* Submit a new version. If {@code CREATE_CBSLOANBASEINTERESTRATE.can_maker_checker=true}, this
* Submit a new version. If {@code CREATE_LOANBASEINTERESTRATE.can_maker_checker=true}, this
* returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source}
* until a checker approves it from the Approvals tab. Otherwise it commits immediately.
*/
@@ -114,7 +126,7 @@ public class LoanProductsBaseInterestRatesApiResource {
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.createCbsLoanBaseInterestRate()
.createLoanBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
@@ -30,9 +30,23 @@ public class LoanRateRegisterSaveRequest {
private String fallbackBelowMin;
private String note;
/**
* Particulars (chart rows) with their GL. A row whose srNo is not yet in the master is created on
* approval and must carry a glAccountId; for an existing row a changed glAccountId/name updates the master.
*/
private List<ProductRequest> products;
private List<SlabRequest> slabs;
private List<CibilSlabRequest> cibilSlabs;
@Getter
@Setter
@NoArgsConstructor
public static class ProductRequest {
private Integer srNo;
private String name;
private Long glAccountId;
}
@Getter
@Setter
@NoArgsConstructor
@@ -0,0 +1,24 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.data;
import lombok.Builder;
import lombok.Getter;
/** Response DTO for one particular (loan type) of the Loan Interest Rate Register. */
@Getter
@Builder
public class LoanRateRegisterProductData {
private Long id;
private Integer srNo;
private String name;
/** Null until the bank maps the particular to a GL account. */
private Long glAccountId;
private Boolean isActive;
}
@@ -20,6 +20,7 @@ import lombok.Getter;
public class LoanRateRegisterSlabData {
private Long id;
private Long productId;
private Integer productSrNo;
private String productName;
private Integer slabOrder;
@@ -17,15 +17,15 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Dispatched by Fineract when a {@code CBSLOANBASEINTERESTRATE} / {@code CREATE} command is executed —
* Dispatched by Fineract when a {@code LOANBASEINTERESTRATE} / {@code CREATE} command is executed —
* on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType}
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createCbsLoanBaseInterestRate()}
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createLoanBaseInterestRate()}
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
*/
@Service
@CommandType(entity = "CBSLOANBASEINTERESTRATE", action = "CREATE")
@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final LoanRateRegisterWritePlatformService writePlatformService;
@@ -61,7 +61,22 @@ public class LoanProductAdvanceExtService {
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased" };
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased",
// Loans A/C (EMI) wizard fields
"securityMandatory", "guarantorsRequired", "coApplicant", "thirdPartySecurity",
"legalOpinionRequired", "propertyInsuranceRequired", "registerCharge", "releaseSecurityOnClosure",
"allowMultipleSecurities", "allowThirdPartyDeposit", "minAge", "maxAgeAtMaturity",
"minCibil", "minMonthlyIncome", "maxFoir", "maxLoansPerCustomer",
"disbursementType", "disbursementMode", "undisbursedInterest", "preEmiInterest",
"emiDueDay", "holidayRule", "moratoriumMonths", "moratoriumInterest",
"variableInstallments", "repaymentMode", "linkedAccountType", "pdcCollection",
"mandateRequired", "advanceEmi", "rateType", "registerParticular",
"applyCibilAdjustment", "rateResolvedAt", "calcPeriod", "interestStartsFrom",
"compounding", "benchmark", "resetFrequency", "resetChange",
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
/**
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
@@ -84,6 +99,18 @@ public class LoanProductAdvanceExtService {
.orElseGet(LinkedHashMap::new);
}
/**
* Every product's satellite in one query, keyed by loan product id. Used by the product list so a screen can
* show and filter products by category and type without one extra call per product.
*/
public Map<Long, Map<String, Object>> retrieveAllAsMap() {
final Map<Long, Map<String, Object>> byProduct = new LinkedHashMap<>();
for (final LoanProductAdvanceExt ext : this.repository.findAll()) {
byProduct.put(ext.getLoanProductId(), toMap(ext));
}
return byProduct;
}
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
for (final String param : ADVANCE_PARAMETERS) {
if (command.parameterExists(param)) {
@@ -205,6 +232,63 @@ public class LoanProductAdvanceExtService {
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
changes);
// Loans A/C (EMI) wizard fields
setString(command, "securityMandatory", ext::getSecurityMandatory, ext::setSecurityMandatory, changes);
setInteger(command, "guarantorsRequired", ext::getGuarantorsRequired, ext::setGuarantorsRequired, changes);
setString(command, "coApplicant", ext::getCoApplicant, ext::setCoApplicant, changes);
setString(command, "thirdPartySecurity", ext::getThirdPartySecurity, ext::setThirdPartySecurity, changes);
setBoolean(command, "legalOpinionRequired", ext::getLegalOpinionRequired, ext::setLegalOpinionRequired, changes);
setBoolean(command, "propertyInsuranceRequired", ext::getPropertyInsuranceRequired, ext::setPropertyInsuranceRequired, changes);
setBoolean(command, "registerCharge", ext::getRegisterCharge, ext::setRegisterCharge, changes);
setBoolean(command, "releaseSecurityOnClosure", ext::getReleaseSecurityOnClosure, ext::setReleaseSecurityOnClosure, changes);
setBoolean(command, "allowMultipleSecurities", ext::getAllowMultipleSecurities, ext::setAllowMultipleSecurities, changes);
setBoolean(command, "allowThirdPartyDeposit", ext::getAllowThirdPartyDeposit, ext::setAllowThirdPartyDeposit, changes);
setInteger(command, "minAge", ext::getMinAge, ext::setMinAge, changes);
setInteger(command, "maxAgeAtMaturity", ext::getMaxAgeAtMaturity, ext::setMaxAgeAtMaturity, changes);
setInteger(command, "minCibil", ext::getMinCibil, ext::setMinCibil, changes);
setDecimal(command, "minMonthlyIncome", ext::getMinMonthlyIncome, ext::setMinMonthlyIncome, changes);
setDecimal(command, "maxFoir", ext::getMaxFoir, ext::setMaxFoir, changes);
setInteger(command, "maxLoansPerCustomer", ext::getMaxLoansPerCustomer, ext::setMaxLoansPerCustomer, changes);
setString(command, "disbursementType", ext::getDisbursementType, ext::setDisbursementType, changes);
setString(command, "disbursementMode", ext::getDisbursementMode, ext::setDisbursementMode, changes);
setString(command, "undisbursedInterest", ext::getUndisbursedInterest, ext::setUndisbursedInterest, changes);
setBoolean(command, "preEmiInterest", ext::getPreEmiInterest, ext::setPreEmiInterest, changes);
setString(command, "emiDueDay", ext::getEmiDueDay, ext::setEmiDueDay, changes);
setString(command, "holidayRule", ext::getHolidayRule, ext::setHolidayRule, changes);
setInteger(command, "moratoriumMonths", ext::getMoratoriumMonths, ext::setMoratoriumMonths, changes);
setString(command, "moratoriumInterest", ext::getMoratoriumInterest, ext::setMoratoriumInterest, changes);
setBoolean(command, "variableInstallments", ext::getVariableInstallments, ext::setVariableInstallments, changes);
setString(command, "repaymentMode", ext::getRepaymentMode, ext::setRepaymentMode, changes);
setString(command, "linkedAccountType", ext::getLinkedAccountType, ext::setLinkedAccountType, changes);
setString(command, "pdcCollection", ext::getPdcCollection, ext::setPdcCollection, changes);
setBoolean(command, "mandateRequired", ext::getMandateRequired, ext::setMandateRequired, changes);
setString(command, "advanceEmi", ext::getAdvanceEmi, ext::setAdvanceEmi, changes);
setString(command, "rateType", ext::getRateType, ext::setRateType, changes);
setString(command, "registerParticular", ext::getRegisterParticular, ext::setRegisterParticular, changes);
setBoolean(command, "applyCibilAdjustment", ext::getApplyCibilAdjustment, ext::setApplyCibilAdjustment, changes);
setString(command, "rateResolvedAt", ext::getRateResolvedAt, ext::setRateResolvedAt, changes);
setString(command, "calcPeriod", ext::getCalcPeriod, ext::setCalcPeriod, changes);
setString(command, "interestStartsFrom", ext::getInterestStartsFrom, ext::setInterestStartsFrom, changes);
setString(command, "compounding", ext::getCompounding, ext::setCompounding, changes);
setString(command, "benchmark", ext::getBenchmark, ext::setBenchmark, changes);
setString(command, "resetFrequency", ext::getResetFrequency, ext::setResetFrequency, changes);
setString(command, "resetChange", ext::getResetChange, ext::setResetChange, changes);
setBoolean(command, "partPrepaymentAllowed", ext::getPartPrepaymentAllowed, ext::setPartPrepaymentAllowed, changes);
setDecimal(command, "minPartPayment", ext::getMinPartPayment, ext::setMinPartPayment, changes);
setString(command, "allowedOn", ext::getAllowedOn, ext::setAllowedOn, changes);
setString(command, "partPaymentsPerYear", ext::getPartPaymentsPerYear, ext::setPartPaymentsPerYear, changes);
setString(command, "partPrepaymentMode", ext::getPartPrepaymentMode, ext::setPartPrepaymentMode, changes);
setDecimal(command, "foreclosureFloatingIndividual", ext::getForeclosureFloatingIndividual, ext::setForeclosureFloatingIndividual, changes);
setDecimal(command, "foreclosureFixedIndividual", ext::getForeclosureFixedIndividual, ext::setForeclosureFixedIndividual, changes);
setDecimal(command, "foreclosureNonIndividual", ext::getForeclosureNonIndividual, ext::setForeclosureNonIndividual, changes);
setDecimal(command, "foreclosureTakeover", ext::getForeclosureTakeover, ext::setForeclosureTakeover, changes);
setInteger(command, "foreclosureQuoteValidDays", ext::getForeclosureQuoteValidDays, ext::setForeclosureQuoteValidDays, changes);
setBoolean(command, "autoCloseOnZeroBalance", ext::getAutoCloseOnZeroBalance, ext::setAutoCloseOnZeroBalance, changes);
setString(command, "nocLetter", ext::getNocLetter, ext::setNocLetter, changes);
setString(command, "restructuring", ext::getRestructuring, ext::setRestructuring, changes);
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
}
// ---- typed setters: only write params that are present, and only report real changes ----------
@@ -368,6 +452,63 @@ public class LoanProductAdvanceExtService {
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
// Loans A/C (EMI) wizard fields
map.put("securityMandatory", ext.getSecurityMandatory());
map.put("guarantorsRequired", ext.getGuarantorsRequired());
map.put("coApplicant", ext.getCoApplicant());
map.put("thirdPartySecurity", ext.getThirdPartySecurity());
map.put("legalOpinionRequired", ext.getLegalOpinionRequired());
map.put("propertyInsuranceRequired", ext.getPropertyInsuranceRequired());
map.put("registerCharge", ext.getRegisterCharge());
map.put("releaseSecurityOnClosure", ext.getReleaseSecurityOnClosure());
map.put("allowMultipleSecurities", ext.getAllowMultipleSecurities());
map.put("allowThirdPartyDeposit", ext.getAllowThirdPartyDeposit());
map.put("minAge", ext.getMinAge());
map.put("maxAgeAtMaturity", ext.getMaxAgeAtMaturity());
map.put("minCibil", ext.getMinCibil());
map.put("minMonthlyIncome", ext.getMinMonthlyIncome());
map.put("maxFoir", ext.getMaxFoir());
map.put("maxLoansPerCustomer", ext.getMaxLoansPerCustomer());
map.put("disbursementType", ext.getDisbursementType());
map.put("disbursementMode", ext.getDisbursementMode());
map.put("undisbursedInterest", ext.getUndisbursedInterest());
map.put("preEmiInterest", ext.getPreEmiInterest());
map.put("emiDueDay", ext.getEmiDueDay());
map.put("holidayRule", ext.getHolidayRule());
map.put("moratoriumMonths", ext.getMoratoriumMonths());
map.put("moratoriumInterest", ext.getMoratoriumInterest());
map.put("variableInstallments", ext.getVariableInstallments());
map.put("repaymentMode", ext.getRepaymentMode());
map.put("linkedAccountType", ext.getLinkedAccountType());
map.put("pdcCollection", ext.getPdcCollection());
map.put("mandateRequired", ext.getMandateRequired());
map.put("advanceEmi", ext.getAdvanceEmi());
map.put("rateType", ext.getRateType());
map.put("registerParticular", ext.getRegisterParticular());
map.put("applyCibilAdjustment", ext.getApplyCibilAdjustment());
map.put("rateResolvedAt", ext.getRateResolvedAt());
map.put("calcPeriod", ext.getCalcPeriod());
map.put("interestStartsFrom", ext.getInterestStartsFrom());
map.put("compounding", ext.getCompounding());
map.put("benchmark", ext.getBenchmark());
map.put("resetFrequency", ext.getResetFrequency());
map.put("resetChange", ext.getResetChange());
map.put("partPrepaymentAllowed", ext.getPartPrepaymentAllowed());
map.put("minPartPayment", ext.getMinPartPayment());
map.put("allowedOn", ext.getAllowedOn());
map.put("partPaymentsPerYear", ext.getPartPaymentsPerYear());
map.put("partPrepaymentMode", ext.getPartPrepaymentMode());
map.put("foreclosureFloatingIndividual", ext.getForeclosureFloatingIndividual());
map.put("foreclosureFixedIndividual", ext.getForeclosureFixedIndividual());
map.put("foreclosureNonIndividual", ext.getForeclosureNonIndividual());
map.put("foreclosureTakeover", ext.getForeclosureTakeover());
map.put("foreclosureQuoteValidDays", ext.getForeclosureQuoteValidDays());
map.put("autoCloseOnZeroBalance", ext.getAutoCloseOnZeroBalance());
map.put("nocLetter", ext.getNocLetter());
map.put("restructuring", ext.getRestructuring());
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
map.put("topUpAllowed", ext.getTopUpAllowed());
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
return map;
}
}
@@ -10,6 +10,7 @@ package org.apache.fineract.portfolio.loanproduct.service;
import java.util.List;
import java.util.Optional;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
public interface LoanRateRegisterReadPlatformService {
@@ -21,4 +22,7 @@ public interface LoanRateRegisterReadPlatformService {
/** A specific version by id. */
Optional<LoanRateRegisterData> retrieveById(Long id);
/** Master list of particulars (loan types) that make up the chart rows, in chart order. */
List<LoanRateRegisterProductData> retrieveProducts();
}
@@ -13,11 +13,14 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.springframework.stereotype.Service;
@@ -33,6 +36,23 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
private final LoanRateRegisterProductRepository productRepository;
@Override
public List<LoanRateRegisterProductData> retrieveProducts() {
final List<LoanRateRegisterProduct> products = this.productRepository.findByIsActiveTrueOrderBySrNoAsc();
final List<LoanRateRegisterProductData> out = new ArrayList<>(products.size());
for (final LoanRateRegisterProduct p : products) {
out.add(LoanRateRegisterProductData.builder()
.id(p.getId())
.srNo(p.getSrNo())
.name(p.getName())
.glAccountId(p.getGlAccountId())
.isActive(p.getIsActive())
.build());
}
return out;
}
@Override
public List<LoanRateRegisterData> retrieveAll() {
@@ -65,6 +85,7 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister
for (final LoanRateRegisterSlabHistory c : cells) {
slabData.add(LoanRateRegisterSlabData.builder()
.id(c.getId())
.productId(c.getProductId())
.productSrNo(c.getProductSrNo())
.productName(c.getProductName())
.slabOrder(c.getSlabOrder())
@@ -10,21 +10,32 @@ package org.apache.fineract.portfolio.loanproduct.service;
import com.google.gson.Gson;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.ProductRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator;
@@ -45,6 +56,8 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
private final LoanRateRegisterProductRepository productRepository;
private final GLAccountRepositoryWrapper glAccountRepositoryWrapper;
private final Gson gson = new Gson();
@Override
@@ -56,6 +69,10 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today);
// 0) Upsert the particulars master first, so a missing GL on a new product fails the whole
// save before any version row is touched. Returns srNo -> master row for the cells below.
final Map<Integer, LoanRateRegisterProduct> productsBySrNo = upsertProducts(req);
// 1) Deactivate the currently-active version (if any).
final List<LoanRateRegisterEffective> active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc();
for (final LoanRateRegisterEffective prior : active) {
@@ -65,17 +82,20 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
this.effectiveRepository.saveAll(active);
}
// 2) Insert the new effective row.
// 2) Insert the new effective row. saveAndFlush ensures Hibernate executes the INSERT
// immediately so the auto-generated id is populated before we use it as the FK on the
// child slab/CIBIL rows (same pattern as FD side's FdBaseInterestRateReadPlatformServiceImpl).
final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom,
req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById);
final LoanRateRegisterEffective saved = this.effectiveRepository.save(fresh);
final LoanRateRegisterEffective saved = this.effectiveRepository.saveAndFlush(fresh);
final Long versionId = saved.getId();
// 3) Insert all chart cells for this version.
final List<LoanRateRegisterSlabHistory> cells = new ArrayList<>(req.getSlabs().size());
for (final SlabRequest s : req.getSlabs()) {
cells.add(LoanRateRegisterSlabHistory.create(versionId, s.getProductSrNo(), s.getProductName(), s.getSlabOrder(),
s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
final Long productId = productsBySrNo.get(s.getProductSrNo()).getId();
cells.add(LoanRateRegisterSlabHistory.create(versionId, productId, s.getProductSrNo(), s.getProductName(),
s.getSlabOrder(), s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa())));
}
this.slabRepository.saveAll(cells);
@@ -94,6 +114,83 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
.build();
}
/**
* Makes sure every particular used by the submitted chart exists in the master. A particular not
* yet in the master is created and must carry a valid GL account; for an existing one a changed
* GL or name is applied. A particular that still has no GL after this (seeded rows the bank has
* not mapped yet) rejects the whole save.
*/
private Map<Integer, LoanRateRegisterProduct> upsertProducts(final LoanRateRegisterSaveRequest req) {
final Map<Integer, LoanRateRegisterProduct> bySrNo = new HashMap<>();
for (final LoanRateRegisterProduct p : this.productRepository.findAll()) {
bySrNo.put(p.getSrNo(), p);
}
final Map<Integer, ProductRequest> requested = new HashMap<>();
if (req.getProducts() != null) {
for (final ProductRequest pr : req.getProducts()) {
requested.put(pr.getSrNo(), pr);
}
}
// Every srNo the chart uses, with a label taken from the first cell that mentions it.
final Set<Integer> used = new LinkedHashSet<>();
final Map<Integer, String> cellLabel = new HashMap<>();
for (final SlabRequest s : req.getSlabs()) {
used.add(s.getProductSrNo());
cellLabel.putIfAbsent(s.getProductSrNo(), s.getProductName());
}
final List<ApiParameterError> errors = new ArrayList<>();
final List<LoanRateRegisterProduct> toSave = new ArrayList<>();
for (final Integer srNo : used) {
final ProductRequest pr = requested.get(srNo);
LoanRateRegisterProduct existing = bySrNo.get(srNo);
final String name = pr != null && pr.getName() != null && !pr.getName().isBlank() ? pr.getName() : cellLabel.get(srNo);
if (existing == null) {
if (pr == null || pr.getGlAccountId() == null) {
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
"New product '" + name + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
continue;
}
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
existing = new LoanRateRegisterProduct();
existing.setSrNo(srNo);
existing.setName(name);
existing.setGlAccountId(pr.getGlAccountId());
existing.setIsActive(true);
existing.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
toSave.add(existing);
} else if (pr != null) {
boolean changed = false;
if (pr.getGlAccountId() != null && !pr.getGlAccountId().equals(existing.getGlAccountId())) {
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
existing.setGlAccountId(pr.getGlAccountId());
changed = true;
}
if (name != null && !name.equals(existing.getName())) {
existing.setName(name);
changed = true;
}
if (changed) {
toSave.add(existing);
}
}
// Every particular in a submitted chart must end up with a GL, including the seeded ones.
if (existing.getGlAccountId() == null) {
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
"Product '" + existing.getName() + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
continue;
}
bySrNo.put(srNo, existing);
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.loanRateRegister.failed", "Validation errors exist.", errors);
}
for (final LoanRateRegisterProduct p : this.productRepository.saveAllAndFlush(toSave)) {
bySrNo.put(p.getSrNo(), p);
}
return bySrNo;
}
private static LocalDate parseDateOrToday(final String iso, final LocalDate today) {
if (iso == null || iso.isBlank()) {
return today;
@@ -619,6 +619,10 @@
<include file="parts/0572_add_loan_product_advance_ext_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0580_create_loan_rate_register_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0581_enable_loan_rate_register_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_loan_rate_register_product_master.xml" relativeToChangelogFile="true"/>
<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"/>
<!-- 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. -->
@@ -28,29 +28,63 @@
Enable maker-checker on the Loan Interest Rate Register. Mirrors 0470 for the FD side.
Two permissions: the maker's gated with can_maker_checker=true so submits land in
m_portfolio_command_source as PENDING; the checker approves from the standard Checker Inbox
via /makercheckers/{id}?command=approve, which fires CreateCbsLoanBaseInterestRateCommandHandler.
via /makercheckers/{id}?command=approve, which fires CreateLoanBaseInterestRateCommandHandler.
Permission codes use the LOANBASEINTERESTRATE entity (no "CBS" prefix) to match the clean
naming convention requested for the Loan module.
-->
<changeSet id="0581a_loan_rate_register_maker_checker_permissions" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE', 'CBSLOANBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE');
-- Rename any previously-seeded CBS-prefixed rows (safe no-op if a fresh DB).
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER', 'CBSLOANBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER');
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE', 'LOANBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE_CHECKER', 'LOANBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE_CHECKER');
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
WHERE entity_name = 'LOANBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_CHECKER'
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
AND code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
WHERE entity_name = 'LOANBASEINTERESTRATE'
AND code = 'CREATE_LOANBASEINTERESTRATE_CHECKER';
</sql>
</changeSet>
<!-- Separate changeset to force the CBS→LOAN rename on DBs that already applied 0581a with the
old names. Liquibase won't re-run an already-applied changeset, so the UPDATE statements
inside 0581a don't fire a second time; this new id runs once per DB on next startup. -->
<changeSet id="0581c_rename_cbs_prefix_to_loanbaseinterestrate" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
-- Rename any pending command-source rows still carrying the old entity string so
-- the handler picks them up after the restart.
UPDATE m_portfolio_command_source
SET entity_name = 'LOANBASEINTERESTRATE'
WHERE entity_name = 'CBSLOANBASEINTERESTRATE';
</sql>
</changeSet>
@@ -63,7 +97,7 @@
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER')
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
@@ -76,7 +110,7 @@
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER')
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
@@ -88,7 +122,7 @@
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code = 'CREATE_CBSLOANBASEINTERESTRATE'
AND p.code = 'CREATE_LOANBASEINTERESTRATE'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
@@ -0,0 +1,199 @@
<?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">
<!--
Master list of "particulars" (loan types) that appear as rows in the Loan Interest Rate
Register chart. The GL account belongs to the particular, not to a version, so it is kept
here and never has to be re-selected when a new rate version is created.
Seeded with the ~16 loan types that almost every bank offers. Bank-specific particulars are
added later through Loan Interest Rate Register > Edit (new version) > Add product, which
requires a GL. gl_account_id is NULL for the seeded rows until the bank maps them.
-->
<changeSet id="0594a_create_loan_rate_register_product" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_product"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_product">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="sr_no" type="INT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_mlrrp_sr_no"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="gl_account_id" type="BIGINT"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_product"
baseColumnNames="gl_account_id"
constraintName="fk_mlrrp_gl_account"
referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<!-- Link chart cells to the particular by id. product_sr_no / product_name stay on the row as the
denormalised label of that version; product_id is the stable link to the master (and its GL).
Nullable so rows written before this changeset survive; backfilled by sr_no below. -->
<changeSet id="0594c_loan_rate_register_slab_history_product_id" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_loan_rate_register_slab_history" columnName="product_id"/></not>
</preConditions>
<addColumn tableName="m_loan_rate_register_slab_history">
<column name="product_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
baseColumnNames="product_id"
constraintName="fk_mlrrsh_product"
referencedTableName="m_loan_rate_register_product"
referencedColumnNames="id"/>
<createIndex indexName="idx_mlrrsh_product" tableName="m_loan_rate_register_slab_history">
<column name="product_id"/>
</createIndex>
</changeSet>
<changeSet id="0594d_backfill_loan_rate_register_slab_history_product_id" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_loan_rate_register_slab_history
SET product_id = (SELECT p.id FROM m_loan_rate_register_product p
WHERE p.sr_no = m_loan_rate_register_slab_history.product_sr_no)
WHERE product_id IS NULL;
</sql>
</changeSet>
<changeSet id="0594b_seed_loan_rate_register_default_products" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_loan_rate_register_product</sqlCheck>
</preConditions>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="1"/>
<column name="name" value="Housing Loan (Purchase/Construction)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="2"/>
<column name="name" value="Home Repair/Renovation"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="3"/>
<column name="name" value="Personal Loan (Unsecured)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="4"/>
<column name="name" value="Personal Loan (Secured/Property)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="5"/>
<column name="name" value="Two Wheeler Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="6"/>
<column name="name" value="Car Loan (New)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="7"/>
<column name="name" value="Used Car/Vehicle Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="8"/>
<column name="name" value="Commercial Vehicle Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="9"/>
<column name="name" value="Gold Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="10"/>
<column name="name" value="Business Term Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="11"/>
<column name="name" value="Machinery &amp; Equipment Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="12"/>
<column name="name" value="Cash Credit"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="13"/>
<column name="name" value="Overdraft"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="14"/>
<column name="name" value="Agriculture Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="15"/>
<column name="name" value="Loan against FDR"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="16"/>
<column name="name" value="Loan against RD"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,95 @@
<?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 portfolio (asset) GL accounts for the Loan Interest Rate Register particulars that had no
GL in the standardized chart of accounts (0503). Existing GLs are reused for the rest:
LOAN AGAINST DEPOSITE 100301, CASH CREDIT 100303, LOAN AGAINST PROPERTY 100401,
PERSONAL LOAN 100402, HOUSE LOAN 100501.
Each row is inserted under its existing header (SHORT/MEDIUM/LONG TERM LOAN) and only when that
header exists, so tenants with a different chart of accounts are left untouched. Codes follow the
0503 scheme: classification(1) + group(2) + sequence(3). Idempotent on gl_code.
-->
<changeSet id="0595_seed_loan_rate_register_gl_accounts" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<sql>
-- SHORT TERM LOAN (100300)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'OVERDRAFT', '100304', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100304');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'GOLD LOAN', '100305', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100305');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'AGRICULTURE LOAN', '100306', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100306');
-- MEDIUM TERM LOAN (100400)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'TWO WHEELER LOAN', '100403', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100403');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'CAR LOAN (NEW)', '100404', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100404');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'USED CAR / VEHICLE LOAN', '100405', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100405');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'COMMERCIAL VEHICLE LOAN', '100406', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100406');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'BUSINESS TERM LOAN', '100407', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100407');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'MACHINERY &amp; EQUIPMENT LOAN', '100408', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100408');
-- LONG TERM LOAN (100500)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'HOME REPAIR / RENOVATION LOAN', '100502', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100500' AND p.name = 'LONG TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100502');
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,111 @@
<?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 Delinquency Ranges and Buckets (the SMA / NPA classification master).
A bucket or range feeds the SMA / NPA class of every loan product linked to it, so create, update
and delete must not take effect on one person's click. The six commands (CREATE / UPDATE / DELETE on
DELINQUENCY_RANGE and DELINQUENCY_BUCKET) are gated with can_maker_checker = true, so each lands in
m_portfolio_command_source as PENDING and is applied only when a checker approves it from the
Checker Inbox. Each gets a matching *_CHECKER permission, which approving requires.
Roles: create / update / delete (maker) and the checker permissions go to Super user and the
bankadmin user's roles only. UPDATE and DELETE are taken away from every other role. CREATE is left
as it is for roles that already hold it. Widen or narrow this later from Admin > Roles.
-->
<changeSet id="0596a_delinquency_maker_checker_permissions" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('CREATE_DELINQUENCY_RANGE', 'UPDATE_DELINQUENCY_RANGE', 'DELETE_DELINQUENCY_RANGE',
'CREATE_DELINQUENCY_BUCKET', 'UPDATE_DELINQUENCY_BUCKET', 'DELETE_DELINQUENCY_BUCKET');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', v.code, v.entity_name, v.action_name, false
FROM (
SELECT 'CREATE_DELINQUENCY_RANGE_CHECKER' AS code, 'DELINQUENCY_RANGE' AS entity_name, 'CREATE_CHECKER' AS action_name
UNION ALL SELECT 'UPDATE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'UPDATE_CHECKER'
UNION ALL SELECT 'DELETE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'DELETE_CHECKER'
UNION ALL SELECT 'CREATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'CREATE_CHECKER'
UNION ALL SELECT 'UPDATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'UPDATE_CHECKER'
UNION ALL SELECT 'DELETE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'DELETE_CHECKER'
) v
WHERE NOT EXISTS (SELECT 1 FROM m_permission p WHERE p.code = v.code);
</sql>
</changeSet>
<changeSet id="0596b_delinquency_permissions_to_admin_roles_only" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Super user: maker + checker for all six commands.
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
-- bankadmin user's role(s): maker + checker for all six commands.
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT ur.role_id, p.id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
-- Every other role loses UPDATE / DELETE on ranges and buckets (and holds no checker permission).
DELETE FROM m_role_permission
WHERE permission_id IN (
SELECT id FROM m_permission
WHERE entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND action_name IN ('UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
)
AND role_id NOT IN (
SELECT id FROM m_role WHERE name = 'Super user'
UNION
SELECT ur.role_id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
WHERE u.username = 'bankadmin'
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,90 @@
<?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">
<!-- Loans A/C (EMI) wizard fields that Fineract's loan-product schema has no home for: collateral and
guarantor rules, borrower eligibility, disbursement, repayment mode, rate source / floating reset,
part-prepayment and foreclosure. Stored on the same per-product satellite as the Advances fields.
The columns are prefixed loan_ so they never clash with an Advances column. -->
<changeSet author="cbsnexora" id="0597_add_loan_product_loans_ext_fields">
<addColumn tableName="m_product_loan_advance_ext">
<column name="loan_security_mandatory" type="VARCHAR(100)"/>
<column name="loan_guarantors_required" type="INT"/>
<column name="loan_co_applicant" type="VARCHAR(100)"/>
<column name="loan_third_party_security" type="VARCHAR(100)"/>
<column name="loan_legal_opinion_required" type="BOOLEAN"/>
<column name="loan_property_insurance_required" type="BOOLEAN"/>
<column name="loan_register_charge" type="BOOLEAN"/>
<column name="loan_release_security_on_closure" type="BOOLEAN"/>
<column name="loan_allow_multiple_securities" type="BOOLEAN"/>
<column name="loan_allow_third_party_deposit" type="BOOLEAN"/>
<column name="loan_min_age" type="INT"/>
<column name="loan_max_age_at_maturity" type="INT"/>
<column name="loan_min_cibil" type="INT"/>
<column name="loan_min_monthly_income" type="DECIMAL(19,6)"/>
<column name="loan_max_foir" type="DECIMAL(19,6)"/>
<column name="loan_max_loans_per_customer" type="INT"/>
<column name="loan_disbursement_type" type="VARCHAR(100)"/>
<column name="loan_disbursement_mode" type="VARCHAR(100)"/>
<column name="loan_undisbursed_interest" type="VARCHAR(100)"/>
<column name="loan_pre_emi_interest" type="BOOLEAN"/>
<column name="loan_emi_due_day" type="VARCHAR(100)"/>
<column name="loan_holiday_rule" type="VARCHAR(100)"/>
<column name="loan_moratorium_months" type="INT"/>
<column name="loan_moratorium_interest" type="VARCHAR(100)"/>
<column name="loan_variable_installments" type="BOOLEAN"/>
<column name="loan_repayment_mode" type="VARCHAR(100)"/>
<column name="loan_linked_account_type" type="VARCHAR(100)"/>
<column name="loan_pdc_collection" type="VARCHAR(100)"/>
<column name="loan_mandate_required" type="BOOLEAN"/>
<column name="loan_advance_emi" type="VARCHAR(100)"/>
<column name="loan_rate_type" type="VARCHAR(100)"/>
<column name="loan_register_particular" type="VARCHAR(100)"/>
<column name="loan_apply_cibil_adjustment" type="BOOLEAN"/>
<column name="loan_rate_resolved_at" type="VARCHAR(100)"/>
<column name="loan_calc_period" type="VARCHAR(100)"/>
<column name="loan_interest_starts_from" type="VARCHAR(100)"/>
<column name="loan_compounding" type="VARCHAR(100)"/>
<column name="loan_benchmark" type="VARCHAR(100)"/>
<column name="loan_reset_frequency" type="VARCHAR(100)"/>
<column name="loan_reset_change" type="VARCHAR(100)"/>
<column name="loan_part_prepayment_allowed" type="BOOLEAN"/>
<column name="loan_min_part_payment" type="DECIMAL(19,6)"/>
<column name="loan_allowed_on" type="VARCHAR(100)"/>
<column name="loan_part_payments_per_year" type="VARCHAR(100)"/>
<column name="loan_part_prepayment_mode" type="VARCHAR(100)"/>
<column name="loan_foreclosure_floating_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_fixed_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_non_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_takeover" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_quote_valid_days" type="INT"/>
<column name="loan_auto_close_on_zero_balance" type="BOOLEAN"/>
<column name="loan_noc_letter" type="VARCHAR(100)"/>
<column name="loan_restructuring" type="VARCHAR(100)"/>
<column name="loan_release_collateral_on_closure" type="BOOLEAN"/>
<column name="loan_top_up_allowed" type="BOOLEAN"/>
<column name="loan_balance_transfer_out_allowed" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>