diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index af0fb4ddd..2094dda38 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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"; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java new file mode 100644 index 000000000..ea16dea10 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlab.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java new file mode 100644 index 000000000..1a0247d1f --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterCibilSlabRepository.java @@ -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 { + + /** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */ + List findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java new file mode 100644 index 000000000..a07160535 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffective.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java new file mode 100644 index 000000000..7e1dd1608 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterEffectiveRepository.java @@ -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 { + + /** All active versions, newest-effective first. At most one row in a healthy system. */ + List findByIsActiveTrueOrderByEffectiveFromDateDesc(); + + /** The current active version, if any. */ + Optional findFirstByIsActiveTrueOrderByEffectiveFromDateDesc(); + + /** Version history list — all versions, newest first. */ + List findAllByOrderByEffectiveFromDateDesc(); +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java new file mode 100644 index 000000000..95d40e024 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistory.java @@ -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; + } +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java new file mode 100644 index 000000000..82e18aaab --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterSlabHistoryRepository.java @@ -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 { + + /** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */ + List findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java new file mode 100644 index 000000000..0d47cb3e7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanProductsBaseInterestRatesApiResource.java @@ -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) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString())) + .registerTypeAdapter(LocalDateTime.class, + (JsonSerializer) (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 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 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java new file mode 100644 index 000000000..175f68476 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/api/LoanRateRegisterSaveRequest.java @@ -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 slabs; + private List 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java new file mode 100644 index 000000000..be88dd50d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterCibilSlabData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java new file mode 100644 index 000000000..4aec4c173 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterData.java @@ -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 slabs; + private List cibilSlabs; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java new file mode 100644 index 000000000..016f98b16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterSlabData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java new file mode 100644 index 000000000..0e6f761b6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java new file mode 100644 index 000000000..aaa855c05 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/serialization/LoanRateRegisterDataValidator.java @@ -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 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 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 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 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 cibil, final List errors) { + final List sorted = new ArrayList<>(cibil); + sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom()))); + final Set 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java new file mode 100644 index 000000000..dba4416db --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformService.java @@ -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 retrieveAll(); + + /** The current active version, assembled with its chart cells + CIBIL bands. */ + Optional retrieveActive(); + + /** A specific version by id. */ + Optional retrieveById(Long id); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java new file mode 100644 index 000000000..02c131798 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterReadPlatformServiceImpl.java @@ -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 retrieveAll() { + final List versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc(); + final List out = new ArrayList<>(versions.size()); + for (final LoanRateRegisterEffective v : versions) { + out.add(assemble(v)); + } + return out; + } + + @Override + public Optional retrieveActive() { + return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble); + } + + @Override + public Optional 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 cells = this.slabRepository + .findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId()); + final List bands = this.cibilRepository + .findByEffectiveIdOrderByDisplayOrderAsc(v.getId()); + + final List 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java new file mode 100644 index 000000000..731bee525 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformService.java @@ -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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java new file mode 100644 index 000000000..dbf58c0e0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/service/LoanRateRegisterWritePlatformServiceImpl.java @@ -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 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 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 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); + } +} diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index ed7e1ca26..3c1bf875d 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -617,4 +617,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml new file mode 100644 index 000000000..85a9a1f3a --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0580_create_loan_rate_register_tables.xml @@ -0,0 +1,193 @@ + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + ANY + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml new file mode 100644 index 000000000..6798d45a9 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0581_enable_loan_rate_register_maker_checker.xml @@ -0,0 +1,99 @@ + + + + + + + + ANY + + 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'; + + + + + ANY + + -- 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 + ); + + + +