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
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:
+8
@@ -2227,6 +2227,14 @@ public class CommandWrapperBuilder {
|
|||||||
return this;
|
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) {
|
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
|
||||||
this.actionName = "UPDATE";
|
this.actionName = "UPDATE";
|
||||||
this.entityName = "FIXEDDEPOSITPRODUCT";
|
this.entityName = "FIXEDDEPOSITPRODUCT";
|
||||||
|
|||||||
+76
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+94
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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();
|
||||||
|
}
|
||||||
+93
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+122
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
@@ -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;
|
||||||
|
}
|
||||||
+38
@@ -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;
|
||||||
|
}
|
||||||
+32
@@ -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;
|
||||||
|
}
|
||||||
+37
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+164
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+24
@@ -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);
|
||||||
|
}
|
||||||
+109
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
+17
@@ -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);
|
||||||
|
}
|
||||||
+103
@@ -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/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0571_create_loan_product_advance_ext.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/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>
|
</databaseChangeLog>
|
||||||
|
|||||||
+193
@@ -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>
|
||||||
+99
@@ -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>
|
||||||
Reference in New Issue
Block a user