feat: add loan interest backend functionality
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 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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
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 Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (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-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-10-07 15:29:18 +05:30
parent b726577031
commit 39e3fcec59
21 changed files with 1359 additions and 0 deletions
@@ -2227,6 +2227,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createCbsLoanBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "CBSLOANBASEINTERESTRATE";
this.entityId = null;
this.href = "/loanproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -0,0 +1,76 @@
/**
* 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.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%.
* Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads
* by {@code displayOrder}.
*/
@Entity
@Table(name = "m_loan_rate_register_cibil_slab")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterCibilSlab {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_id", nullable = false)
private Long effectiveId;
@Column(name = "score_from", nullable = false)
private Integer scoreFrom;
@Column(name = "score_to", nullable = false)
private Integer scoreTo;
@Column(name = "tier", length = 30, nullable = false)
private String tier;
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "adjustment_pct", nullable = false)
private BigDecimal adjustmentPct;
@Column(name = "display_order", nullable = false)
private Integer displayOrder;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier,
final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) {
final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab();
s.effectiveId = effectiveId;
s.scoreFrom = scoreFrom;
s.scoreTo = scoreTo;
s.tier = tier;
s.slabType = slabType != null ? slabType : "Flat";
s.adjustmentPct = adjustmentPct;
s.displayOrder = displayOrder;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -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 LoanRateRegisterCibilSlabRepository extends JpaRepository<LoanRateRegisterCibilSlab, Long> {
/** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */
List<LoanRateRegisterCibilSlab> findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId);
}
@@ -0,0 +1,94 @@
/**
* 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.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the
* {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with
* {@code isActive=true} and deactivates the previous active row ({@code isActive=false,
* effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}.
*
* The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory};
* the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's
* id and cascade-deleted with it.
*/
@Entity
@Table(name = "m_loan_rate_register_effective")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterEffective {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "effective_from_date", nullable = false)
private LocalDate effectiveFromDate;
@Column(name = "effective_to_date")
private LocalDate effectiveToDate;
@Column(name = "currency_code", length = 3, nullable = false)
private String currencyCode;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "fallback_no_cibil_slab", nullable = false)
private Integer fallbackNoCibilSlab;
@Column(name = "fallback_below_min", length = 20, nullable = false)
private String fallbackBelowMin;
@Column(name = "note", length = 500)
private String note;
@Column(name = "created_by_id")
private Long createdById;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode,
final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) {
final LoanRateRegisterEffective e = new LoanRateRegisterEffective();
e.name = name;
e.effectiveFromDate = effectiveFromDate;
e.currencyCode = currencyCode != null ? currencyCode : "INR";
e.isActive = true;
e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2;
e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block";
e.note = note;
e.createdById = createdById;
e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return e;
}
/** Flip this version to inactive and close its effective window. Called when a newer version is approved. */
public void deactivate(final LocalDate toDate) {
this.isActive = false;
this.effectiveToDate = toDate;
}
}
@@ -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.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterEffectiveRepository extends JpaRepository<LoanRateRegisterEffective, Long> {
/** All active versions, newest-effective first. At most one row in a healthy system. */
List<LoanRateRegisterEffective> findByIsActiveTrueOrderByEffectiveFromDateDesc();
/** The current active version, if any. */
Optional<LoanRateRegisterEffective> findFirstByIsActiveTrueOrderByEffectiveFromDateDesc();
/** Version history list — all versions, newest first. */
List<LoanRateRegisterEffective> findAllByOrderByEffectiveFromDateDesc();
}
@@ -0,0 +1,93 @@
/**
* 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.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* A single cell of a rate register version — one row per {product × amount-slab}. Product and slab
* metadata are denormalised so one query can rebuild the full matrix without a join. The cell holds
* either a plain numeric rate, a formula string ({@code "1% + FD"}) resolved at sanction, or
* N.A. (both value and formula null, {@code isNa=true}) to mean "slab not offered".
*/
@Entity
@Table(name = "m_loan_rate_register_slab_history")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_id", nullable = false)
private Long effectiveId;
// ---- product dimension ----------------------------------------------
@Column(name = "product_sr_no", nullable = false)
private Integer productSrNo;
@Column(name = "product_name", length = 200, nullable = false)
private String productName;
// ---- amount-slab dimension ------------------------------------------
@Column(name = "slab_order", nullable = false)
private Integer slabOrder;
@Column(name = "slab_label", length = 60, nullable = false)
private String slabLabel;
@Column(name = "amount_upto")
private BigDecimal amountUpto;
// ---- cell value (exactly one of value / formula is populated; both null + isNa=true means N.A.) ----
@Column(name = "rate_value")
private BigDecimal rateValue;
@Column(name = "rate_formula", length = 50)
private String rateFormula;
@Column(name = "is_formula", nullable = false)
private Boolean isFormula;
@Column(name = "is_na", nullable = false)
private Boolean isNa;
@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) {
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
s.effectiveId = effectiveId;
s.productSrNo = productSrNo;
s.productName = productName;
s.slabOrder = slabOrder;
s.slabLabel = slabLabel;
s.amountUpto = amountUpto;
s.rateValue = rateValue;
s.rateFormula = rateFormula;
s.isFormula = isFormula;
s.isNa = isNa;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -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 LoanRateRegisterSlabHistoryRepository extends JpaRepository<LoanRateRegisterSlabHistory, Long> {
/** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */
List<LoanRateRegisterSlabHistory> findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId);
}
@@ -0,0 +1,122 @@
/**
* 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.api;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import com.google.gson.JsonPrimitive;
import com.google.gson.JsonSerializer;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterReadPlatformService;
import org.springframework.stereotype.Component;
/**
* REST surface for the Loan Interest Rate Register. Mirrors
* {@code FixedDepositProductsBaseInterestRatesApiResource}: a GET list for the Version History
* accordion, a GET /active for the current rate chart, a GET /{id} for one specific version, and
* 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}.
*/
@Path("/v1/loanproducts/baseinterestrates")
@Component
@RequiredArgsConstructor
public class LoanProductsBaseInterestRatesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOANPRODUCT";
private final PlatformSecurityContext context;
private final LoanRateRegisterReadPlatformService readService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
.registerTypeAdapter(LocalDateTime.class,
(JsonSerializer<LocalDateTime>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)))
.create();
/** Full Version History — all effective versions, newest first. */
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get all Loan Interest Rate Register versions", description = "Returns the full history of rate register versions, newest first.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final List<LoanRateRegisterData> data = this.readService.retrieveAll();
return this.gson.toJson(data);
}
/** The currently in-force version, with its full chart + CIBIL. */
@GET
@Path("active")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get the active Loan Interest Rate Register", description = "Returns the version flagged isActive=true, or null when none has been approved yet.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveActive() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final Optional<LoanRateRegisterData> data = this.readService.retrieveActive();
return this.gson.toJson(data.orElse(null));
}
/** One specific version by id — used by the Compare screen for the "will be deactivated" baseline. */
@GET
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get a specific rate register version", description = "Returns the full chart + CIBIL bands for the given version id.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "404", description = "Not found") })
public String retrieveById(@PathParam("id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final Optional<LoanRateRegisterData> data = this.readService.retrieveById(id);
return this.gson.toJson(data.orElse(null));
}
/**
* Submit a new version. If {@code CREATE_CBSLOANBASEINTERESTRATE.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.
*/
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Submit a new Loan Interest Rate Register version", description = "Creates a new version of the loan interest rate register. Deactivates the current active version on approval.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.createCbsLoanBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -0,0 +1,62 @@
/**
* 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.api;
import java.math.BigDecimal;
import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* JSON shape expected by {@code POST /loanproducts/baseinterestrates}. Deserialised by Gson in the
* write service. One request = one proposed new version of the rate register.
*/
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterSaveRequest {
private String name;
/** ISO date "YYYY-MM-DD". Defaults to today when omitted. */
private String effectiveFromDate;
private String currencyCode;
private Integer fallbackNoCibilSlab;
private String fallbackBelowMin;
private String note;
private List<SlabRequest> slabs;
private List<CibilSlabRequest> cibilSlabs;
@Getter
@Setter
@NoArgsConstructor
public static class SlabRequest {
private Integer productSrNo;
private String productName;
private Integer slabOrder;
private String slabLabel;
private BigDecimal amountUpto;
private BigDecimal rateValue;
private String rateFormula;
private Boolean isFormula;
private Boolean isNa;
}
@Getter
@Setter
@NoArgsConstructor
public static class CibilSlabRequest {
private Integer scoreFrom;
private Integer scoreTo;
private String tier;
private String slabType;
private BigDecimal adjustmentPct;
private Integer displayOrder;
}
}
@@ -0,0 +1,28 @@
/**
* 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 java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/**
* One CIBIL/credit-score band of a rate register version, used in the response payload.
*/
@Getter
@Builder
public class LoanRateRegisterCibilSlabData {
private Long id;
private Integer scoreFrom;
private Integer scoreTo;
private String tier;
private String slabType;
private BigDecimal adjustmentPct;
private Integer displayOrder;
}
@@ -0,0 +1,38 @@
/**
* 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 java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Response DTO for one effective version of the Loan Interest Rate Register. Header fields +
* nested chart cells + CIBIL bands.
*/
@Getter
@Builder
public class LoanRateRegisterData {
private Long id;
private String name;
private LocalDate effectiveFromDate;
private LocalDate effectiveToDate;
private String currencyCode;
private Boolean isActive;
private Integer fallbackNoCibilSlab;
private String fallbackBelowMin;
private String note;
private Long createdById;
private LocalDateTime createdDate;
private List<LoanRateRegisterSlabData> slabs;
private List<LoanRateRegisterCibilSlabData> cibilSlabs;
}
@@ -0,0 +1,32 @@
/**
* 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 java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/**
* One cell of a rate register version — same shape as the entity but a POJO for the response.
* Serialised through Gson in the REST resource.
*/
@Getter
@Builder
public class LoanRateRegisterSlabData {
private Long id;
private Integer productSrNo;
private String productName;
private Integer slabOrder;
private String slabLabel;
private BigDecimal amountUpto;
private BigDecimal rateValue;
private String rateFormula;
private Boolean isFormula;
private Boolean isNa;
}
@@ -0,0 +1,37 @@
/**
* 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.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Dispatched by Fineract when a {@code CBSLOANBASEINTERESTRATE} / {@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()}
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
*/
@Service
@CommandType(entity = "CBSLOANBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final LoanRateRegisterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveRateRegister(command);
}
}
@@ -0,0 +1,164 @@
/**
* 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.serialization;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
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.SlabRequest;
import org.springframework.stereotype.Component;
/**
* Validates a {@link LoanRateRegisterSaveRequest} before the write service touches the DB. Keeps
* the write service free of guard clauses and surfaces every error up front rather than failing on
* the first one — Fineract convention.
*/
@Component
public class LoanRateRegisterDataValidator {
private static final String RESOURCE_NAME = "loanRateRegister";
public void validate(final LoanRateRegisterSaveRequest req) {
final List<ApiParameterError> errors = new ArrayList<>();
if (req == null) {
errors.add(error("payload", "payload.required", "Request body is required"));
raise(errors);
}
// Header fields
if (isBlank(req.getName())) {
errors.add(error("name", "name.required", "Register name is required"));
} else if (req.getName().length() > 200) {
errors.add(error("name", "name.exceeds.max.length", "Register name must be at most 200 characters"));
}
if (req.getFallbackNoCibilSlab() != null && req.getFallbackNoCibilSlab() < 1) {
errors.add(error("fallbackNoCibilSlab", "fallback.no.cibil.slab.invalid", "Fallback slab pointer must be >= 1"));
}
if (req.getFallbackBelowMin() != null && !req.getFallbackBelowMin().equals("use-min-slab")
&& !req.getFallbackBelowMin().equals("block")) {
errors.add(error("fallbackBelowMin", "fallback.below.min.invalid",
"fallbackBelowMin must be 'use-min-slab' or 'block'"));
}
// Chart cells
final List<SlabRequest> slabs = req.getSlabs();
if (slabs == null || slabs.isEmpty()) {
errors.add(error("slabs", "slabs.required", "At least one rate chart cell is required"));
} else {
for (int i = 0; i < slabs.size(); i++) {
validateSlab(slabs.get(i), i, errors);
}
}
// CIBIL bands
final List<CibilSlabRequest> cibil = req.getCibilSlabs();
if (cibil == null || cibil.isEmpty()) {
errors.add(error("cibilSlabs", "cibilSlabs.required", "At least one CIBIL slab is required"));
} else {
validateCibilCoverage(cibil, errors);
}
raise(errors);
}
private void validateSlab(final SlabRequest s, final int idx, final List<ApiParameterError> errors) {
final String prefix = "slabs[" + idx + "]";
if (s.getProductSrNo() == null) {
errors.add(error(prefix + ".productSrNo", "productSrNo.required", "productSrNo is required"));
}
if (isBlank(s.getProductName())) {
errors.add(error(prefix + ".productName", "productName.required", "productName is required"));
}
if (s.getSlabOrder() == null) {
errors.add(error(prefix + ".slabOrder", "slabOrder.required", "slabOrder is required"));
}
if (isBlank(s.getSlabLabel())) {
errors.add(error(prefix + ".slabLabel", "slabLabel.required", "slabLabel is required"));
}
// Exactly one of: rateValue populated, rateFormula populated, isNa=true. Both-null-and-not-NA is invalid.
final boolean hasValue = s.getRateValue() != null;
final boolean hasFormula = s.getRateFormula() != null && !s.getRateFormula().isBlank();
final boolean na = Boolean.TRUE.equals(s.getIsNa());
final int populated = (hasValue ? 1 : 0) + (hasFormula ? 1 : 0) + (na ? 1 : 0);
if (populated == 0) {
errors.add(error(prefix, "cell.empty",
"Cell must have a rateValue, a rateFormula, or isNa=true"));
} else if (populated > 1) {
errors.add(error(prefix, "cell.ambiguous",
"Cell must have exactly one of rateValue, rateFormula, or isNa"));
}
if (hasFormula && !Boolean.TRUE.equals(s.getIsFormula())) {
errors.add(error(prefix + ".isFormula", "isFormula.mismatch",
"isFormula must be true when rateFormula is set"));
}
}
/** CIBIL bands must cover 0..900 contiguously and must not overlap. */
private void validateCibilCoverage(final List<CibilSlabRequest> cibil, final List<ApiParameterError> errors) {
final List<CibilSlabRequest> sorted = new ArrayList<>(cibil);
sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom())));
final Set<Integer> seenOrders = new TreeSet<>();
int expectedNextFrom = 0;
for (int i = 0; i < sorted.size(); i++) {
final CibilSlabRequest c = sorted.get(i);
final String prefix = "cibilSlabs[" + i + "]";
if (c.getScoreFrom() == null || c.getScoreTo() == null) {
errors.add(error(prefix, "cibil.range.required", "scoreFrom and scoreTo are required"));
continue;
}
if (c.getScoreFrom() > c.getScoreTo()) {
errors.add(error(prefix, "cibil.range.inverted", "scoreFrom must be <= scoreTo"));
}
if (c.getScoreFrom() != expectedNextFrom) {
errors.add(error(prefix, "cibil.range.gap_or_overlap",
"CIBIL bands must be contiguous starting from 0 (expected " + expectedNextFrom
+ ", got " + c.getScoreFrom() + ")"));
}
if (isBlank(c.getTier())) {
errors.add(error(prefix + ".tier", "tier.required", "tier is required"));
}
if (c.getAdjustmentPct() == null) {
errors.add(error(prefix + ".adjustmentPct", "adjustmentPct.required", "adjustmentPct is required"));
}
if (c.getDisplayOrder() == null || !seenOrders.add(c.getDisplayOrder())) {
errors.add(error(prefix + ".displayOrder", "displayOrder.invalid",
"displayOrder is required and must be unique"));
}
expectedNextFrom = c.getScoreTo() + 1;
}
if (!sorted.isEmpty() && expectedNextFrom <= 900) {
errors.add(error("cibilSlabs", "cibil.coverage.incomplete",
"CIBIL bands must cover up to 900 (last to was " + (expectedNextFrom - 1) + ")"));
}
}
private static ApiParameterError error(final String field, final String code, final String msg) {
return ApiParameterError.parameterError("validation." + RESOURCE_NAME + "." + code, msg, field);
}
private static void raise(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg." + RESOURCE_NAME + ".failed",
"Validation errors exist.", errors);
}
}
private static boolean isBlank(final String s) {
return s == null || s.isBlank();
}
private static int nz(final Integer n) {
return n != null ? n : 0;
}
}
@@ -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.service;
import java.util.List;
import java.util.Optional;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
public interface LoanRateRegisterReadPlatformService {
/** All versions, newest-effective first — the Version History accordion feeds off this. */
List<LoanRateRegisterData> retrieveAll();
/** The current active version, assembled with its chart cells + CIBIL bands. */
Optional<LoanRateRegisterData> retrieveActive();
/** A specific version by id. */
Optional<LoanRateRegisterData> retrieveById(Long id);
}
@@ -0,0 +1,109 @@
/**
* 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.service;
import java.util.ArrayList;
import java.util.List;
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.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.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.springframework.stereotype.Service;
/**
* Assembles {@link LoanRateRegisterData} responses by joining the three physical tables. Kept
* read-only — all writes go through {@link LoanRateRegisterWritePlatformService}.
*/
@Service
@RequiredArgsConstructor
public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegisterReadPlatformService {
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
@Override
public List<LoanRateRegisterData> retrieveAll() {
final List<LoanRateRegisterEffective> versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc();
final List<LoanRateRegisterData> out = new ArrayList<>(versions.size());
for (final LoanRateRegisterEffective v : versions) {
out.add(assemble(v));
}
return out;
}
@Override
public Optional<LoanRateRegisterData> retrieveActive() {
return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble);
}
@Override
public Optional<LoanRateRegisterData> retrieveById(final Long id) {
return this.effectiveRepository.findById(id).map(this::assemble);
}
/** Build the full response for one effective version — header + its chart cells + its CIBIL bands. */
private LoanRateRegisterData assemble(final LoanRateRegisterEffective v) {
final List<LoanRateRegisterSlabHistory> cells = this.slabRepository
.findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId());
final List<LoanRateRegisterCibilSlab> bands = this.cibilRepository
.findByEffectiveIdOrderByDisplayOrderAsc(v.getId());
final List<LoanRateRegisterSlabData> slabData = new ArrayList<>(cells.size());
for (final LoanRateRegisterSlabHistory c : cells) {
slabData.add(LoanRateRegisterSlabData.builder()
.id(c.getId())
.productSrNo(c.getProductSrNo())
.productName(c.getProductName())
.slabOrder(c.getSlabOrder())
.slabLabel(c.getSlabLabel())
.amountUpto(c.getAmountUpto())
.rateValue(c.getRateValue())
.rateFormula(c.getRateFormula())
.isFormula(c.getIsFormula())
.isNa(c.getIsNa())
.build());
}
final List<LoanRateRegisterCibilSlabData> cibilData = new ArrayList<>(bands.size());
for (final LoanRateRegisterCibilSlab b : bands) {
cibilData.add(LoanRateRegisterCibilSlabData.builder()
.id(b.getId())
.scoreFrom(b.getScoreFrom())
.scoreTo(b.getScoreTo())
.tier(b.getTier())
.slabType(b.getSlabType())
.adjustmentPct(b.getAdjustmentPct())
.displayOrder(b.getDisplayOrder())
.build());
}
return LoanRateRegisterData.builder()
.id(v.getId())
.name(v.getName())
.effectiveFromDate(v.getEffectiveFromDate())
.effectiveToDate(v.getEffectiveToDate())
.currencyCode(v.getCurrencyCode())
.isActive(v.getIsActive())
.fallbackNoCibilSlab(v.getFallbackNoCibilSlab())
.fallbackBelowMin(v.getFallbackBelowMin())
.note(v.getNote())
.createdById(v.getCreatedById())
.createdDate(v.getCreatedDate())
.slabs(slabData)
.cibilSlabs(cibilData)
.build();
}
}
@@ -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.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface LoanRateRegisterWritePlatformService {
/** Save a new version of the loan interest rate register (maker-checker gated upstream). */
CommandProcessingResult saveRateRegister(JsonCommand command);
}
@@ -0,0 +1,103 @@
/**
* 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.service;
import com.google.gson.Gson;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.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.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator;
import org.springframework.stereotype.Service;
/**
* Persists a new effective version of the Loan Interest Rate Register. Called from the command
* handler, which Fineract routes to only AFTER a checker has approved the maker's submission. The
* whole save runs inside one transaction: previous active deactivated → new effective inserted →
* all chart cells + CIBIL bands inserted. Any failure rolls everything back.
*/
@Service
@RequiredArgsConstructor
public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegisterWritePlatformService {
private final PlatformSecurityContext context;
private final LoanRateRegisterDataValidator validator;
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
private final Gson gson = new Gson();
@Override
public CommandProcessingResult saveRateRegister(final JsonCommand command) {
final LoanRateRegisterSaveRequest req = gson.fromJson(command.json(), LoanRateRegisterSaveRequest.class);
this.validator.validate(req);
final Long createdById = this.context.authenticatedUser().getId();
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today);
// 1) Deactivate the currently-active version (if any).
final List<LoanRateRegisterEffective> active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc();
for (final LoanRateRegisterEffective prior : active) {
prior.deactivate(today);
}
if (!active.isEmpty()) {
this.effectiveRepository.saveAll(active);
}
// 2) Insert the new effective row.
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 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(),
Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa())));
}
this.slabRepository.saveAll(cells);
// 4) Insert all CIBIL bands for this version.
final List<LoanRateRegisterCibilSlab> bands = new ArrayList<>(req.getCibilSlabs().size());
for (final CibilSlabRequest c : req.getCibilSlabs()) {
bands.add(LoanRateRegisterCibilSlab.create(versionId, c.getScoreFrom(), c.getScoreTo(), c.getTier(),
c.getSlabType(), c.getAdjustmentPct(), c.getDisplayOrder()));
}
this.cibilRepository.saveAll(bands);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withEntityId(versionId)
.build();
}
private static LocalDate parseDateOrToday(final String iso, final LocalDate today) {
if (iso == null || iso.isBlank()) {
return today;
}
return LocalDate.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE);
}
}
@@ -617,4 +617,6 @@
<include file="parts/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_create_loan_product_advance_ext.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,193 @@
<?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">
<!--
Central Loan Interest Rate Register — one approved rate chart (34 products × 10 amount
slabs) + CIBIL/credit-score adjustment bands. Mirrors the "CBS Base Interest Rate" (FD)
versioning model: each save creates a new effective row and deactivates the previous one.
Maker-checker gated through Fineract's standard command-source mechanism (see 0581).
-->
<!-- 1. Version header: one row per effective version. is_active flags the currently in-force one. -->
<changeSet id="0580a_create_loan_rate_register_effective" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_effective"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_effective">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="effective_from_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="effective_to_date" type="DATE"/>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<!-- Fallback behaviour when a loan's CIBIL score is unavailable: pointer into the CIBIL
slab list for this version (1-indexed, matches UI). -->
<column name="fallback_no_cibil_slab" type="INT" defaultValueNumeric="2">
<constraints nullable="false"/>
</column>
<!-- Fallback when CIBIL < min configured slab: 'use-min-slab' or 'block'. -->
<column name="fallback_below_min" type="VARCHAR(20)" defaultValue="block">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="created_by_id" type="BIGINT"/>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 2. Rate chart cells: one row per {version × product × amount-slab}. Product name and slab
label are denormalised for query convenience + display. Cell value is either a plain rate,
a formula string ("1% + FD"), or N.A. (both value and formula null, is_na=true). -->
<changeSet id="0580b_create_loan_rate_register_slab_history" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_slab_history"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_slab_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="effective_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Product dimension -->
<column name="product_sr_no" type="INT">
<constraints nullable="false"/>
</column>
<column name="product_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<!-- Amount-slab dimension -->
<column name="slab_order" type="INT">
<constraints nullable="false"/>
</column>
<column name="slab_label" type="VARCHAR(60)">
<constraints nullable="false"/>
</column>
<column name="amount_upto" type="DECIMAL(19,2)"/>
<!-- Cell value: at most ONE of rate_value / rate_formula is non-null; both null + is_na=true means slab not offered. -->
<column name="rate_value" type="DECIMAL(9,4)"/>
<column name="rate_formula" type="VARCHAR(50)"/>
<column name="is_formula" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="is_na" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 3. CIBIL/credit-score adjustment bands: 5 rows per version (typical), layered on top of the
chart rate at loan sanction. -->
<changeSet id="0580c_create_loan_rate_register_cibil_slab" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_cibil_slab"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_cibil_slab">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="effective_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="score_from" type="INT">
<constraints nullable="false"/>
</column>
<column name="score_to" type="INT">
<constraints nullable="false"/>
</column>
<column name="tier" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="slab_type" type="VARCHAR(20)" defaultValue="Flat">
<constraints nullable="false"/>
</column>
<column name="adjustment_pct" type="DECIMAL(5,2)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="display_order" type="INT">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 4. Foreign keys — effective_id in child tables cascades on delete so retiring a version
cleans up its slabs and CIBIL rows atomically. -->
<changeSet id="0580d_loan_rate_register_fks" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
baseColumnNames="effective_id"
constraintName="fk_mlrrsh_effective"
referencedTableName="m_loan_rate_register_effective"
referencedColumnNames="id"
onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_cibil_slab"
baseColumnNames="effective_id"
constraintName="fk_mlrrcs_effective"
referencedTableName="m_loan_rate_register_effective"
referencedColumnNames="id"
onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_effective"
baseColumnNames="created_by_id"
constraintName="fk_mlrre_created_by"
referencedTableName="m_appuser"
referencedColumnNames="id"/>
</changeSet>
<!-- 5. Indexes for the hot read paths: (a) "give me the current active" (b) "give me a version's
rate for a product+slab" (c) "give me a version's CIBIL slabs ordered". -->
<changeSet id="0580e_loan_rate_register_indexes" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<createIndex indexName="idx_mlrre_active" tableName="m_loan_rate_register_effective">
<column name="is_active"/>
<column name="effective_from_date"/>
</createIndex>
<createIndex indexName="idx_mlrrsh_lookup" tableName="m_loan_rate_register_slab_history">
<column name="effective_id"/>
<column name="product_sr_no"/>
<column name="slab_order"/>
</createIndex>
<createIndex indexName="idx_mlrrcs_order" tableName="m_loan_rate_register_cibil_slab">
<column name="effective_id"/>
<column name="display_order"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,99 @@
<?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">
<!--
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.
-->
<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');
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');
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_CHECKER'
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
AND code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
</sql>
</changeSet>
<changeSet id="0581b_grant_loan_rate_register_permissions_to_roles" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Super user: both maker and checker
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.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_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): both maker and checker
INSERT INTO m_role_permission (role_id, permission_id)
SELECT 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.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
-- Clerk: maker only (separation of duties — clerks submit, higher authority approves)
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code = 'CREATE_CBSLOANBASEINTERESTRATE'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>