diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 591e46473..3b12899f3 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -157,6 +157,7 @@ public class CommandSourceService { } if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName()) || "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) + || "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName()) || "CHARGE".equalsIgnoreCase(commandSource.getEntityName()) || "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) || "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName()) 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 98ff9772f..0b31388f5 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 @@ -2299,9 +2299,9 @@ public class CommandWrapperBuilder { return this; } - public CommandWrapperBuilder createCbsLoanBaseInterestRate() { + public CommandWrapperBuilder createLoanBaseInterestRate() { this.actionName = "CREATE"; - this.entityName = "CBSLOANBASEINTERESTRATE"; + this.entityName = "LOANBASEINTERESTRATE"; this.entityId = null; this.href = "/loanproducts/baseinterestrates"; return this; diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProduct.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProduct.java new file mode 100644 index 000000000..8b82d5739 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProduct.java @@ -0,0 +1,52 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package org.apache.fineract.portfolio.loanproduct.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +/** + * A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a + * property of the particular, so it lives here rather than on each rate-register version. Kept as a + * plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity + * graph into the loan module. + */ +@Entity +@Table(name = "m_loan_rate_register_product") +@Getter +@Setter +@NoArgsConstructor +public class LoanRateRegisterProduct { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "sr_no", nullable = false) + private Integer srNo; + + @Column(name = "name", length = 200, nullable = false) + private String name; + + @Column(name = "gl_account_id") + private Long glAccountId; + + @Column(name = "is_active", nullable = false) + private Boolean isActive; + + @Column(name = "created_date") + private LocalDateTime createdDate; +} diff --git a/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProductRepository.java b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProductRepository.java new file mode 100644 index 000000000..0024d1f94 --- /dev/null +++ b/fineract-loan/src/main/java/org/apache/fineract/portfolio/loanproduct/domain/LoanRateRegisterProductRepository.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 LoanRateRegisterProductRepository extends JpaRepository { + + /** Active particulars in chart order. */ + List findByIsActiveTrueOrderBySrNoAsc(); +} 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 index 95d40e024..7ffde2a32 100644 --- 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 @@ -41,6 +41,10 @@ public class LoanRateRegisterSlabHistory { private Long effectiveId; // ---- product dimension ---------------------------------------------- + /** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */ + @Column(name = "product_id") + private Long productId; + @Column(name = "product_sr_no", nullable = false) private Integer productSrNo; @@ -73,11 +77,12 @@ public class LoanRateRegisterSlabHistory { @Column(name = "created_date") private LocalDateTime createdDate; - public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Integer productSrNo, final String productName, - final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto, final BigDecimal rateValue, - final String rateFormula, final boolean isFormula, final boolean isNa) { + public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo, + final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto, + final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) { final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory(); s.effectiveId = effectiveId; + s.productId = productId; s.productSrNo = productSrNo; s.productName = productName; s.slabOrder = slabOrder; 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 index 0d47cb3e7..1382b267b 100644 --- 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 @@ -44,7 +44,7 @@ import org.springframework.stereotype.Component; * a POST that goes through Fineract's maker-checker pipeline. * * Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded - * permissions {@code CREATE_CBSLOANBASEINTERESTRATE} (maker) and {@code CREATE_CBSLOANBASEINTERESTRATE_CHECKER}. + * permissions {@code CREATE_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}. */ @Path("/v1/loanproducts/baseinterestrates") @Component @@ -76,6 +76,18 @@ public class LoanProductsBaseInterestRatesApiResource { return this.gson.toJson(data); } + /** Master list of particulars (loan types) that make up the chart rows. Literal path wins over {id}. */ + @GET + @Path("products") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Get Loan Interest Rate Register particulars", description = "Returns the master list of loan types (chart rows) with their mapped GL account id, in chart order.") + @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) + public String retrieveProducts() { + this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS); + return this.gson.toJson(this.readService.retrieveProducts()); + } + /** The currently in-force version, with its full chart + CIBIL. */ @GET @Path("active") @@ -103,7 +115,7 @@ public class LoanProductsBaseInterestRatesApiResource { } /** - * Submit a new version. If {@code CREATE_CBSLOANBASEINTERESTRATE.can_maker_checker=true}, this + * Submit a new version. If {@code CREATE_LOANBASEINTERESTRATE.can_maker_checker=true}, this * returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source} * until a checker approves it from the Approvals tab. Otherwise it commits immediately. */ @@ -114,7 +126,7 @@ public class LoanProductsBaseInterestRatesApiResource { @ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") }) public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) { final CommandWrapper commandRequest = new CommandWrapperBuilder() - .createCbsLoanBaseInterestRate() + .createLoanBaseInterestRate() .withJson(apiRequestBodyAsJson) .build(); return this.commandsSourceWritePlatformService.logCommandSource(commandRequest); 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 index 175f68476..7d1a913d3 100644 --- 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 @@ -30,9 +30,23 @@ public class LoanRateRegisterSaveRequest { private String fallbackBelowMin; private String note; + /** + * Particulars (chart rows) with their GL. A row whose srNo is not yet in the master is created on + * approval and must carry a glAccountId; for an existing row a changed glAccountId/name updates the master. + */ + private List products; private List slabs; private List cibilSlabs; + @Getter + @Setter + @NoArgsConstructor + public static class ProductRequest { + private Integer srNo; + private String name; + private Long glAccountId; + } + @Getter @Setter @NoArgsConstructor diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterProductData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterProductData.java new file mode 100644 index 000000000..96cabe402 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/data/LoanRateRegisterProductData.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.data; + +import lombok.Builder; +import lombok.Getter; + +/** Response DTO for one particular (loan type) of the Loan Interest Rate Register. */ +@Getter +@Builder +public class LoanRateRegisterProductData { + + private Long id; + private Integer srNo; + private String name; + /** Null until the bank maps the particular to a GL account. */ + private Long glAccountId; + private Boolean isActive; +} 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 index 016f98b16..e09b2ffcc 100644 --- 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 @@ -20,6 +20,7 @@ import lombok.Getter; public class LoanRateRegisterSlabData { private Long id; + private Long productId; private Integer productSrNo; private String productName; private Integer slabOrder; 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/CreateLoanBaseInterestRateCommandHandler.java similarity index 81% rename from fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateCbsLoanBaseInterestRateCommandHandler.java rename to fineract-provider/src/main/java/org/apache/fineract/portfolio/loanproduct/handler/CreateLoanBaseInterestRateCommandHandler.java index 0e6f761b6..0146122b9 100644 --- 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/CreateLoanBaseInterestRateCommandHandler.java @@ -17,15 +17,15 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; /** - * Dispatched by Fineract when a {@code CBSLOANBASEINTERESTRATE} / {@code CREATE} command is executed — + * Dispatched by Fineract when a {@code LOANBASEINTERESTRATE} / {@code CREATE} command is executed — * on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType} - * entity string here MUST match the one registered by {@code CommandWrapperBuilder.createCbsLoanBaseInterestRate()} + * entity string here MUST match the one registered by {@code CommandWrapperBuilder.createLoanBaseInterestRate()} * and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}. */ @Service -@CommandType(entity = "CBSLOANBASEINTERESTRATE", action = "CREATE") +@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE") @RequiredArgsConstructor -public class CreateCbsLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler { +public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler { private final LoanRateRegisterWritePlatformService writePlatformService; 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 index dba4416db..9dd0e4287 100644 --- 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 @@ -10,6 +10,7 @@ package org.apache.fineract.portfolio.loanproduct.service; import java.util.List; import java.util.Optional; import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData; public interface LoanRateRegisterReadPlatformService { @@ -21,4 +22,7 @@ public interface LoanRateRegisterReadPlatformService { /** A specific version by id. */ Optional retrieveById(Long id); + + /** Master list of particulars (loan types) that make up the chart rows, in chart order. */ + List retrieveProducts(); } 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 index 02c131798..9577935d0 100644 --- 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 @@ -13,11 +13,14 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData; import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData; +import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData; import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository; import org.springframework.stereotype.Service; @@ -33,6 +36,23 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister private final LoanRateRegisterEffectiveRepository effectiveRepository; private final LoanRateRegisterSlabHistoryRepository slabRepository; private final LoanRateRegisterCibilSlabRepository cibilRepository; + private final LoanRateRegisterProductRepository productRepository; + + @Override + public List retrieveProducts() { + final List products = this.productRepository.findByIsActiveTrueOrderBySrNoAsc(); + final List out = new ArrayList<>(products.size()); + for (final LoanRateRegisterProduct p : products) { + out.add(LoanRateRegisterProductData.builder() + .id(p.getId()) + .srNo(p.getSrNo()) + .name(p.getName()) + .glAccountId(p.getGlAccountId()) + .isActive(p.getIsActive()) + .build()); + } + return out; + } @Override public List retrieveAll() { @@ -65,6 +85,7 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister for (final LoanRateRegisterSlabHistory c : cells) { slabData.add(LoanRateRegisterSlabData.builder() .id(c.getId()) + .productId(c.getProductId()) .productSrNo(c.getProductSrNo()) .productName(c.getProductName()) .slabOrder(c.getSlabOrder()) 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 index dbf58c0e0..0c09e7df9 100644 --- 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 @@ -10,21 +10,32 @@ package org.apache.fineract.portfolio.loanproduct.service; import com.google.gson.Gson; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest; import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest; +import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.ProductRequest; import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct; +import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory; import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository; import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator; @@ -45,6 +56,8 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste private final LoanRateRegisterEffectiveRepository effectiveRepository; private final LoanRateRegisterSlabHistoryRepository slabRepository; private final LoanRateRegisterCibilSlabRepository cibilRepository; + private final LoanRateRegisterProductRepository productRepository; + private final GLAccountRepositoryWrapper glAccountRepositoryWrapper; private final Gson gson = new Gson(); @Override @@ -56,6 +69,10 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today); + // 0) Upsert the particulars master first, so a missing GL on a new product fails the whole + // save before any version row is touched. Returns srNo -> master row for the cells below. + final Map productsBySrNo = upsertProducts(req); + // 1) Deactivate the currently-active version (if any). final List active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc(); for (final LoanRateRegisterEffective prior : active) { @@ -65,17 +82,20 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste this.effectiveRepository.saveAll(active); } - // 2) Insert the new effective row. + // 2) Insert the new effective row. saveAndFlush ensures Hibernate executes the INSERT + // immediately so the auto-generated id is populated before we use it as the FK on the + // child slab/CIBIL rows (same pattern as FD side's FdBaseInterestRateReadPlatformServiceImpl). final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom, req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById); - final LoanRateRegisterEffective saved = this.effectiveRepository.save(fresh); + final LoanRateRegisterEffective saved = this.effectiveRepository.saveAndFlush(fresh); final Long versionId = saved.getId(); // 3) Insert all chart cells for this version. final List cells = new ArrayList<>(req.getSlabs().size()); for (final SlabRequest s : req.getSlabs()) { - cells.add(LoanRateRegisterSlabHistory.create(versionId, s.getProductSrNo(), s.getProductName(), s.getSlabOrder(), - s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(), + final Long productId = productsBySrNo.get(s.getProductSrNo()).getId(); + cells.add(LoanRateRegisterSlabHistory.create(versionId, productId, s.getProductSrNo(), s.getProductName(), + s.getSlabOrder(), s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(), Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa()))); } this.slabRepository.saveAll(cells); @@ -94,6 +114,83 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste .build(); } + /** + * Makes sure every particular used by the submitted chart exists in the master. A particular not + * yet in the master is created and must carry a valid GL account; for an existing one a changed + * GL or name is applied. A particular that still has no GL after this (seeded rows the bank has + * not mapped yet) rejects the whole save. + */ + private Map upsertProducts(final LoanRateRegisterSaveRequest req) { + final Map bySrNo = new HashMap<>(); + for (final LoanRateRegisterProduct p : this.productRepository.findAll()) { + bySrNo.put(p.getSrNo(), p); + } + final Map requested = new HashMap<>(); + if (req.getProducts() != null) { + for (final ProductRequest pr : req.getProducts()) { + requested.put(pr.getSrNo(), pr); + } + } + // Every srNo the chart uses, with a label taken from the first cell that mentions it. + final Set used = new LinkedHashSet<>(); + final Map cellLabel = new HashMap<>(); + for (final SlabRequest s : req.getSlabs()) { + used.add(s.getProductSrNo()); + cellLabel.putIfAbsent(s.getProductSrNo(), s.getProductName()); + } + + final List errors = new ArrayList<>(); + final List toSave = new ArrayList<>(); + for (final Integer srNo : used) { + final ProductRequest pr = requested.get(srNo); + LoanRateRegisterProduct existing = bySrNo.get(srNo); + final String name = pr != null && pr.getName() != null && !pr.getName().isBlank() ? pr.getName() : cellLabel.get(srNo); + if (existing == null) { + if (pr == null || pr.getGlAccountId() == null) { + errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required", + "New product '" + name + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId")); + continue; + } + this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId()); + existing = new LoanRateRegisterProduct(); + existing.setSrNo(srNo); + existing.setName(name); + existing.setGlAccountId(pr.getGlAccountId()); + existing.setIsActive(true); + existing.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant())); + toSave.add(existing); + } else if (pr != null) { + boolean changed = false; + if (pr.getGlAccountId() != null && !pr.getGlAccountId().equals(existing.getGlAccountId())) { + this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId()); + existing.setGlAccountId(pr.getGlAccountId()); + changed = true; + } + if (name != null && !name.equals(existing.getName())) { + existing.setName(name); + changed = true; + } + if (changed) { + toSave.add(existing); + } + } + // Every particular in a submitted chart must end up with a GL, including the seeded ones. + if (existing.getGlAccountId() == null) { + errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required", + "Product '" + existing.getName() + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId")); + continue; + } + bySrNo.put(srNo, existing); + } + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.loanRateRegister.failed", "Validation errors exist.", errors); + } + for (final LoanRateRegisterProduct p : this.productRepository.saveAllAndFlush(toSave)) { + bySrNo.put(p.getSrNo(), p); + } + return bySrNo; + } + private static LocalDate parseDateOrToday(final String iso, final LocalDate today) { if (iso == null || iso.isBlank()) { return today; 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 3c1bf875d..9fa49d704 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 @@ -619,4 +619,6 @@ + + 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 index 6798d45a9..dac63301d 100644 --- 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 @@ -28,29 +28,63 @@ Enable maker-checker on the Loan Interest Rate Register. Mirrors 0470 for the FD side. Two permissions: the maker's gated with can_maker_checker=true so submits land in m_portfolio_command_source as PENDING; the checker approves from the standard Checker Inbox - via /makercheckers/{id}?command=approve, which fires CreateCbsLoanBaseInterestRateCommandHandler. + via /makercheckers/{id}?command=approve, which fires CreateLoanBaseInterestRateCommandHandler. + + Permission codes use the LOANBASEINTERESTRATE entity (no "CBS" prefix) to match the clean + naming convention requested for the Loan module. --> 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'); + -- Rename any previously-seeded CBS-prefixed rows (safe no-op if a fresh DB). + UPDATE m_permission + SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE' + WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE'; + + UPDATE m_permission + SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE' + WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'; INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) - SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER', 'CBSLOANBASEINTERESTRATE', 'CREATE_CHECKER', false - WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'); + SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE', 'LOANBASEINTERESTRATE', 'CREATE', true + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE'); + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE_CHECKER', 'LOANBASEINTERESTRATE', 'CREATE_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE_CHECKER'); UPDATE m_permission SET can_maker_checker = TRUE - WHERE entity_name = 'CBSLOANBASEINTERESTRATE' + WHERE entity_name = 'LOANBASEINTERESTRATE' AND action_name = 'CREATE'; UPDATE m_permission SET action_name = 'CREATE_CHECKER' - WHERE entity_name = 'CBSLOANBASEINTERESTRATE' - AND code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'; + WHERE entity_name = 'LOANBASEINTERESTRATE' + AND code = 'CREATE_LOANBASEINTERESTRATE_CHECKER'; + + + + + + ANY + + UPDATE m_permission + SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE' + WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE'; + + UPDATE m_permission + SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE' + WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER'; + + -- Rename any pending command-source rows still carrying the old entity string so + -- the handler picks them up after the restart. + UPDATE m_portfolio_command_source + SET entity_name = 'LOANBASEINTERESTRATE' + WHERE entity_name = 'CBSLOANBASEINTERESTRATE'; @@ -63,7 +97,7 @@ FROM m_role r CROSS JOIN m_permission p WHERE r.name = 'Super user' - AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER') + AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER') AND NOT EXISTS ( SELECT 1 FROM m_role_permission rp WHERE rp.role_id = r.id AND rp.permission_id = p.id @@ -76,7 +110,7 @@ JOIN m_appuser_role ur ON ur.appuser_id = u.id CROSS JOIN m_permission p WHERE u.username = 'bankadmin' - AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER') + AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER') AND NOT EXISTS ( SELECT 1 FROM m_role_permission rp WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id @@ -88,7 +122,7 @@ FROM m_role r CROSS JOIN m_permission p WHERE LOWER(r.name) = 'clerk' - AND p.code = 'CREATE_CBSLOANBASEINTERESTRATE' + AND p.code = 'CREATE_LOANBASEINTERESTRATE' AND NOT EXISTS ( SELECT 1 FROM m_role_permission rp WHERE rp.role_id = r.id AND rp.permission_id = p.id diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_loan_rate_register_product_master.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_loan_rate_register_product_master.xml new file mode 100644 index 000000000..c764e781f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_loan_rate_register_product_master.xml @@ -0,0 +1,199 @@ + + + + + + + + ANY + + + + + + + + + + + + + + + + + + + + + + + + + ANY + + + + + + + + + + + + + + ANY + + UPDATE m_loan_rate_register_slab_history + SET product_id = (SELECT p.id FROM m_loan_rate_register_product p + WHERE p.sr_no = m_loan_rate_register_slab_history.product_sr_no) + WHERE product_id IS NULL; + + + + + ANY + + SELECT COUNT(*) FROM m_loan_rate_register_product + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_seed_loan_rate_register_gl_accounts.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_seed_loan_rate_register_gl_accounts.xml new file mode 100644 index 000000000..f56f20106 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_seed_loan_rate_register_gl_accounts.xml @@ -0,0 +1,95 @@ + + + + + + + ANY + + -- SHORT TERM LOAN (100300) + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'OVERDRAFT', '100304', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100304'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'GOLD LOAN', '100305', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100305'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'AGRICULTURE LOAN', '100306', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100306'); + + -- MEDIUM TERM LOAN (100400) + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'TWO WHEELER LOAN', '100403', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100403'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'CAR LOAN (NEW)', '100404', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100404'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'USED CAR / VEHICLE LOAN', '100405', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100405'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'COMMERCIAL VEHICLE LOAN', '100406', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100406'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'BUSINESS TERM LOAN', '100407', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100407'); + + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'MACHINERY & EQUIPMENT LOAN', '100408', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100408'); + + -- LONG TERM LOAN (100500) + INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id) + SELECT 'HOME REPAIR / RENOVATION LOAN', '100502', false, true, 1, 1, p.id FROM acc_gl_account p + WHERE p.gl_code = '100500' AND p.name = 'LONG TERM LOAN' + AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100502'); + + + +