feat(loan-products): add product master table and GL account seeding for loan interest rate registe

This commit is contained in:
2026-10-08 17:29:39 +05:30
parent 919bfb5204
commit a127710442
17 changed files with 606 additions and 28 deletions
@@ -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())
@@ -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;
@@ -0,0 +1,52 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* A "particular" (loan type) row of the Loan Interest Rate Register chart. The GL account is a
* property of the particular, so it lives here rather than on each rate-register version. Kept as a
* plain {@code gl_account_id} column (no relation) to avoid pulling the accounting module's entity
* graph into the loan module.
*/
@Entity
@Table(name = "m_loan_rate_register_product")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterProduct {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "sr_no", nullable = false)
private Integer srNo;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "created_date")
private LocalDateTime createdDate;
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterProductRepository extends JpaRepository<LoanRateRegisterProduct, Long> {
/** Active particulars in chart order. */
List<LoanRateRegisterProduct> findByIsActiveTrueOrderBySrNoAsc();
}
@@ -41,6 +41,10 @@ public class LoanRateRegisterSlabHistory {
private Long effectiveId;
// ---- product dimension ----------------------------------------------
/** Stable link to the master particular (and its GL). Null only for rows written before the master existed. */
@Column(name = "product_id")
private Long productId;
@Column(name = "product_sr_no", nullable = false)
private Integer productSrNo;
@@ -73,11 +77,12 @@ public class LoanRateRegisterSlabHistory {
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Integer productSrNo, final String productName,
final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto, final BigDecimal rateValue,
final String rateFormula, final boolean isFormula, final boolean isNa) {
public static LoanRateRegisterSlabHistory create(final Long effectiveId, final Long productId, final Integer productSrNo,
final String productName, final Integer slabOrder, final String slabLabel, final BigDecimal amountUpto,
final BigDecimal rateValue, final String rateFormula, final boolean isFormula, final boolean isNa) {
final LoanRateRegisterSlabHistory s = new LoanRateRegisterSlabHistory();
s.effectiveId = effectiveId;
s.productId = productId;
s.productSrNo = productSrNo;
s.productName = productName;
s.slabOrder = slabOrder;
@@ -44,7 +44,7 @@ import org.springframework.stereotype.Component;
* a POST that goes through Fineract's maker-checker pipeline.
*
* Reads are gated by the pre-existing {@code LOANPRODUCT} resource; writes by the two seeded
* permissions {@code CREATE_CBSLOANBASEINTERESTRATE} (maker) and {@code CREATE_CBSLOANBASEINTERESTRATE_CHECKER}.
* permissions {@code CREATE_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}.
*/
@Path("/v1/loanproducts/baseinterestrates")
@Component
@@ -76,6 +76,18 @@ public class LoanProductsBaseInterestRatesApiResource {
return this.gson.toJson(data);
}
/** Master list of particulars (loan types) that make up the chart rows. Literal path wins over {id}. */
@GET
@Path("products")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get Loan Interest Rate Register particulars", description = "Returns the master list of loan types (chart rows) with their mapped GL account id, in chart order.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveProducts() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
return this.gson.toJson(this.readService.retrieveProducts());
}
/** The currently in-force version, with its full chart + CIBIL. */
@GET
@Path("active")
@@ -103,7 +115,7 @@ public class LoanProductsBaseInterestRatesApiResource {
}
/**
* Submit a new version. If {@code CREATE_CBSLOANBASEINTERESTRATE.can_maker_checker=true}, this
* Submit a new version. If {@code CREATE_LOANBASEINTERESTRATE.can_maker_checker=true}, this
* returns {@code rollbackTransaction=true} and the row sits in {@code m_portfolio_command_source}
* until a checker approves it from the Approvals tab. Otherwise it commits immediately.
*/
@@ -114,7 +126,7 @@ public class LoanProductsBaseInterestRatesApiResource {
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public CommandProcessingResult submitRateRegister(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.createCbsLoanBaseInterestRate()
.createLoanBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
@@ -30,9 +30,23 @@ public class LoanRateRegisterSaveRequest {
private String fallbackBelowMin;
private String note;
/**
* Particulars (chart rows) with their GL. A row whose srNo is not yet in the master is created on
* approval and must carry a glAccountId; for an existing row a changed glAccountId/name updates the master.
*/
private List<ProductRequest> products;
private List<SlabRequest> slabs;
private List<CibilSlabRequest> cibilSlabs;
@Getter
@Setter
@NoArgsConstructor
public static class ProductRequest {
private Integer srNo;
private String name;
private Long glAccountId;
}
@Getter
@Setter
@NoArgsConstructor
@@ -0,0 +1,24 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.data;
import lombok.Builder;
import lombok.Getter;
/** Response DTO for one particular (loan type) of the Loan Interest Rate Register. */
@Getter
@Builder
public class LoanRateRegisterProductData {
private Long id;
private Integer srNo;
private String name;
/** Null until the bank maps the particular to a GL account. */
private Long glAccountId;
private Boolean isActive;
}
@@ -20,6 +20,7 @@ import lombok.Getter;
public class LoanRateRegisterSlabData {
private Long id;
private Long productId;
private Integer productSrNo;
private String productName;
private Integer slabOrder;
@@ -17,15 +17,15 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Dispatched by Fineract when a {@code CBSLOANBASEINTERESTRATE} / {@code CREATE} command is executed —
* Dispatched by Fineract when a {@code LOANBASEINTERESTRATE} / {@code CREATE} command is executed —
* on first submit (maker-checker off) or on checker approval (maker-checker on). The {@code @CommandType}
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createCbsLoanBaseInterestRate()}
* entity string here MUST match the one registered by {@code CommandWrapperBuilder.createLoanBaseInterestRate()}
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
*/
@Service
@CommandType(entity = "CBSLOANBASEINTERESTRATE", action = "CREATE")
@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateCbsLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final LoanRateRegisterWritePlatformService writePlatformService;
@@ -10,6 +10,7 @@ package org.apache.fineract.portfolio.loanproduct.service;
import java.util.List;
import java.util.Optional;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
public interface LoanRateRegisterReadPlatformService {
@@ -21,4 +22,7 @@ public interface LoanRateRegisterReadPlatformService {
/** A specific version by id. */
Optional<LoanRateRegisterData> retrieveById(Long id);
/** Master list of particulars (loan types) that make up the chart rows, in chart order. */
List<LoanRateRegisterProductData> retrieveProducts();
}
@@ -13,11 +13,14 @@ import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterCibilSlabData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterProductData;
import org.apache.fineract.portfolio.loanproduct.data.LoanRateRegisterSlabData;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.springframework.stereotype.Service;
@@ -33,6 +36,23 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
private final LoanRateRegisterProductRepository productRepository;
@Override
public List<LoanRateRegisterProductData> retrieveProducts() {
final List<LoanRateRegisterProduct> products = this.productRepository.findByIsActiveTrueOrderBySrNoAsc();
final List<LoanRateRegisterProductData> out = new ArrayList<>(products.size());
for (final LoanRateRegisterProduct p : products) {
out.add(LoanRateRegisterProductData.builder()
.id(p.getId())
.srNo(p.getSrNo())
.name(p.getName())
.glAccountId(p.getGlAccountId())
.isActive(p.getIsActive())
.build());
}
return out;
}
@Override
public List<LoanRateRegisterData> retrieveAll() {
@@ -65,6 +85,7 @@ public class LoanRateRegisterReadPlatformServiceImpl implements LoanRateRegister
for (final LoanRateRegisterSlabHistory c : cells) {
slabData.add(LoanRateRegisterSlabData.builder()
.id(c.getId())
.productId(c.getProductId())
.productSrNo(c.getProductSrNo())
.productName(c.getProductName())
.slabOrder(c.getSlabOrder())
@@ -10,21 +10,32 @@ package org.apache.fineract.portfolio.loanproduct.service;
import com.google.gson.Gson;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.ProductRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlab;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterCibilSlabRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffective;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterEffectiveRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterProductRepository;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistory;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRateRegisterSlabHistoryRepository;
import org.apache.fineract.portfolio.loanproduct.serialization.LoanRateRegisterDataValidator;
@@ -45,6 +56,8 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
private final LoanRateRegisterEffectiveRepository effectiveRepository;
private final LoanRateRegisterSlabHistoryRepository slabRepository;
private final LoanRateRegisterCibilSlabRepository cibilRepository;
private final LoanRateRegisterProductRepository productRepository;
private final GLAccountRepositoryWrapper glAccountRepositoryWrapper;
private final Gson gson = new Gson();
@Override
@@ -56,6 +69,10 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate effectiveFrom = parseDateOrToday(req.getEffectiveFromDate(), today);
// 0) Upsert the particulars master first, so a missing GL on a new product fails the whole
// save before any version row is touched. Returns srNo -> master row for the cells below.
final Map<Integer, LoanRateRegisterProduct> productsBySrNo = upsertProducts(req);
// 1) Deactivate the currently-active version (if any).
final List<LoanRateRegisterEffective> active = this.effectiveRepository.findByIsActiveTrueOrderByEffectiveFromDateDesc();
for (final LoanRateRegisterEffective prior : active) {
@@ -65,17 +82,20 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
this.effectiveRepository.saveAll(active);
}
// 2) Insert the new effective row.
// 2) Insert the new effective row. saveAndFlush ensures Hibernate executes the INSERT
// immediately so the auto-generated id is populated before we use it as the FK on the
// child slab/CIBIL rows (same pattern as FD side's FdBaseInterestRateReadPlatformServiceImpl).
final LoanRateRegisterEffective fresh = LoanRateRegisterEffective.create(req.getName(), effectiveFrom,
req.getCurrencyCode(), req.getFallbackNoCibilSlab(), req.getFallbackBelowMin(), req.getNote(), createdById);
final LoanRateRegisterEffective saved = this.effectiveRepository.save(fresh);
final LoanRateRegisterEffective saved = this.effectiveRepository.saveAndFlush(fresh);
final Long versionId = saved.getId();
// 3) Insert all chart cells for this version.
final List<LoanRateRegisterSlabHistory> cells = new ArrayList<>(req.getSlabs().size());
for (final SlabRequest s : req.getSlabs()) {
cells.add(LoanRateRegisterSlabHistory.create(versionId, s.getProductSrNo(), s.getProductName(), s.getSlabOrder(),
s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
final Long productId = productsBySrNo.get(s.getProductSrNo()).getId();
cells.add(LoanRateRegisterSlabHistory.create(versionId, productId, s.getProductSrNo(), s.getProductName(),
s.getSlabOrder(), s.getSlabLabel(), s.getAmountUpto(), s.getRateValue(), s.getRateFormula(),
Boolean.TRUE.equals(s.getIsFormula()), Boolean.TRUE.equals(s.getIsNa())));
}
this.slabRepository.saveAll(cells);
@@ -94,6 +114,83 @@ public class LoanRateRegisterWritePlatformServiceImpl implements LoanRateRegiste
.build();
}
/**
* Makes sure every particular used by the submitted chart exists in the master. A particular not
* yet in the master is created and must carry a valid GL account; for an existing one a changed
* GL or name is applied. A particular that still has no GL after this (seeded rows the bank has
* not mapped yet) rejects the whole save.
*/
private Map<Integer, LoanRateRegisterProduct> upsertProducts(final LoanRateRegisterSaveRequest req) {
final Map<Integer, LoanRateRegisterProduct> bySrNo = new HashMap<>();
for (final LoanRateRegisterProduct p : this.productRepository.findAll()) {
bySrNo.put(p.getSrNo(), p);
}
final Map<Integer, ProductRequest> requested = new HashMap<>();
if (req.getProducts() != null) {
for (final ProductRequest pr : req.getProducts()) {
requested.put(pr.getSrNo(), pr);
}
}
// Every srNo the chart uses, with a label taken from the first cell that mentions it.
final Set<Integer> used = new LinkedHashSet<>();
final Map<Integer, String> cellLabel = new HashMap<>();
for (final SlabRequest s : req.getSlabs()) {
used.add(s.getProductSrNo());
cellLabel.putIfAbsent(s.getProductSrNo(), s.getProductName());
}
final List<ApiParameterError> errors = new ArrayList<>();
final List<LoanRateRegisterProduct> toSave = new ArrayList<>();
for (final Integer srNo : used) {
final ProductRequest pr = requested.get(srNo);
LoanRateRegisterProduct existing = bySrNo.get(srNo);
final String name = pr != null && pr.getName() != null && !pr.getName().isBlank() ? pr.getName() : cellLabel.get(srNo);
if (existing == null) {
if (pr == null || pr.getGlAccountId() == null) {
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
"New product '" + name + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
continue;
}
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
existing = new LoanRateRegisterProduct();
existing.setSrNo(srNo);
existing.setName(name);
existing.setGlAccountId(pr.getGlAccountId());
existing.setIsActive(true);
existing.setCreatedDate(LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant()));
toSave.add(existing);
} else if (pr != null) {
boolean changed = false;
if (pr.getGlAccountId() != null && !pr.getGlAccountId().equals(existing.getGlAccountId())) {
this.glAccountRepositoryWrapper.findOneWithNotFoundDetection(pr.getGlAccountId());
existing.setGlAccountId(pr.getGlAccountId());
changed = true;
}
if (name != null && !name.equals(existing.getName())) {
existing.setName(name);
changed = true;
}
if (changed) {
toSave.add(existing);
}
}
// Every particular in a submitted chart must end up with a GL, including the seeded ones.
if (existing.getGlAccountId() == null) {
errors.add(ApiParameterError.parameterError("validation.loanRateRegister.product.gl.required",
"Product '" + existing.getName() + "' (Sr. " + srNo + ") needs a GL account", "products[" + srNo + "].glAccountId"));
continue;
}
bySrNo.put(srNo, existing);
}
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.loanRateRegister.failed", "Validation errors exist.", errors);
}
for (final LoanRateRegisterProduct p : this.productRepository.saveAllAndFlush(toSave)) {
bySrNo.put(p.getSrNo(), p);
}
return bySrNo;
}
private static LocalDate parseDateOrToday(final String iso, final LocalDate today) {
if (iso == null || iso.isBlank()) {
return today;
@@ -619,4 +619,6 @@
<include file="parts/0572_add_loan_product_advance_ext_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0580_create_loan_rate_register_tables.xml" relativeToChangelogFile="true"/>
<include file="parts/0581_enable_loan_rate_register_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_loan_rate_register_product_master.xml" relativeToChangelogFile="true"/>
<include file="parts/0595_seed_loan_rate_register_gl_accounts.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -28,29 +28,63 @@
Enable maker-checker on the Loan Interest Rate Register. Mirrors 0470 for the FD side.
Two permissions: the maker's gated with can_maker_checker=true so submits land in
m_portfolio_command_source as PENDING; the checker approves from the standard Checker Inbox
via /makercheckers/{id}?command=approve, which fires CreateCbsLoanBaseInterestRateCommandHandler.
via /makercheckers/{id}?command=approve, which fires CreateLoanBaseInterestRateCommandHandler.
Permission codes use the LOANBASEINTERESTRATE entity (no "CBS" prefix) to match the clean
naming convention requested for the Loan module.
-->
<changeSet id="0581a_loan_rate_register_maker_checker_permissions" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE', 'CBSLOANBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE');
-- Rename any previously-seeded CBS-prefixed rows (safe no-op if a fresh DB).
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER', 'CBSLOANBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER');
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE', 'LOANBASEINTERESTRATE', 'CREATE', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_LOANBASEINTERESTRATE_CHECKER', 'LOANBASEINTERESTRATE', 'CREATE_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'CREATE_LOANBASEINTERESTRATE_CHECKER');
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
WHERE entity_name = 'LOANBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_CHECKER'
WHERE entity_name = 'CBSLOANBASEINTERESTRATE'
AND code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
WHERE entity_name = 'LOANBASEINTERESTRATE'
AND code = 'CREATE_LOANBASEINTERESTRATE_CHECKER';
</sql>
</changeSet>
<!-- Separate changeset to force the CBS→LOAN rename on DBs that already applied 0581a with the
old names. Liquibase won't re-run an already-applied changeset, so the UPDATE statements
inside 0581a don't fire a second time; this new id runs once per DB on next startup. -->
<changeSet id="0581c_rename_cbs_prefix_to_loanbaseinterestrate" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE';
UPDATE m_permission
SET code = 'CREATE_LOANBASEINTERESTRATE_CHECKER', entity_name = 'LOANBASEINTERESTRATE'
WHERE code = 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER';
-- Rename any pending command-source rows still carrying the old entity string so
-- the handler picks them up after the restart.
UPDATE m_portfolio_command_source
SET entity_name = 'LOANBASEINTERESTRATE'
WHERE entity_name = 'CBSLOANBASEINTERESTRATE';
</sql>
</changeSet>
@@ -63,7 +97,7 @@
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER')
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
@@ -76,7 +110,7 @@
JOIN m_appuser_role ur ON ur.appuser_id = u.id
CROSS JOIN m_permission p
WHERE u.username = 'bankadmin'
AND p.code IN ('CREATE_CBSLOANBASEINTERESTRATE', 'CREATE_CBSLOANBASEINTERESTRATE_CHECKER')
AND p.code IN ('CREATE_LOANBASEINTERESTRATE', 'CREATE_LOANBASEINTERESTRATE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
@@ -88,7 +122,7 @@
FROM m_role r
CROSS JOIN m_permission p
WHERE LOWER(r.name) = 'clerk'
AND p.code = 'CREATE_CBSLOANBASEINTERESTRATE'
AND p.code = 'CREATE_LOANBASEINTERESTRATE'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
@@ -0,0 +1,199 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Master list of "particulars" (loan types) that appear as rows in the Loan Interest Rate
Register chart. The GL account belongs to the particular, not to a version, so it is kept
here and never has to be re-selected when a new rate version is created.
Seeded with the ~16 loan types that almost every bank offers. Bank-specific particulars are
added later through Loan Interest Rate Register > Edit (new version) > Add product, which
requires a GL. gl_account_id is NULL for the seeded rows until the bank maps them.
-->
<changeSet id="0594a_create_loan_rate_register_product" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_product"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_product">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="sr_no" type="INT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_mlrrp_sr_no"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="gl_account_id" type="BIGINT"/>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_product"
baseColumnNames="gl_account_id"
constraintName="fk_mlrrp_gl_account"
referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
<!-- Link chart cells to the particular by id. product_sr_no / product_name stay on the row as the
denormalised label of that version; product_id is the stable link to the master (and its GL).
Nullable so rows written before this changeset survive; backfilled by sr_no below. -->
<changeSet id="0594c_loan_rate_register_slab_history_product_id" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_loan_rate_register_slab_history" columnName="product_id"/></not>
</preConditions>
<addColumn tableName="m_loan_rate_register_slab_history">
<column name="product_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
baseColumnNames="product_id"
constraintName="fk_mlrrsh_product"
referencedTableName="m_loan_rate_register_product"
referencedColumnNames="id"/>
<createIndex indexName="idx_mlrrsh_product" tableName="m_loan_rate_register_slab_history">
<column name="product_id"/>
</createIndex>
</changeSet>
<changeSet id="0594d_backfill_loan_rate_register_slab_history_product_id" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_loan_rate_register_slab_history
SET product_id = (SELECT p.id FROM m_loan_rate_register_product p
WHERE p.sr_no = m_loan_rate_register_slab_history.product_sr_no)
WHERE product_id IS NULL;
</sql>
</changeSet>
<changeSet id="0594b_seed_loan_rate_register_default_products" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_loan_rate_register_product</sqlCheck>
</preConditions>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="1"/>
<column name="name" value="Housing Loan (Purchase/Construction)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="2"/>
<column name="name" value="Home Repair/Renovation"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="3"/>
<column name="name" value="Personal Loan (Unsecured)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="4"/>
<column name="name" value="Personal Loan (Secured/Property)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="5"/>
<column name="name" value="Two Wheeler Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="6"/>
<column name="name" value="Car Loan (New)"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="7"/>
<column name="name" value="Used Car/Vehicle Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="8"/>
<column name="name" value="Commercial Vehicle Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="9"/>
<column name="name" value="Gold Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="10"/>
<column name="name" value="Business Term Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="11"/>
<column name="name" value="Machinery &amp; Equipment Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="12"/>
<column name="name" value="Cash Credit"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="13"/>
<column name="name" value="Overdraft"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="14"/>
<column name="name" value="Agriculture Loan"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="15"/>
<column name="name" value="Loan against FDR"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
<insert tableName="m_loan_rate_register_product">
<column name="sr_no" valueNumeric="16"/>
<column name="name" value="Loan against RD"/>
<column name="is_active" valueBoolean="true"/>
<column name="created_date" valueComputed="NOW()"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Loan portfolio (asset) GL accounts for the Loan Interest Rate Register particulars that had no
GL in the standardized chart of accounts (0503). Existing GLs are reused for the rest:
LOAN AGAINST DEPOSITE 100301, CASH CREDIT 100303, LOAN AGAINST PROPERTY 100401,
PERSONAL LOAN 100402, HOUSE LOAN 100501.
Each row is inserted under its existing header (SHORT/MEDIUM/LONG TERM LOAN) and only when that
header exists, so tenants with a different chart of accounts are left untouched. Codes follow the
0503 scheme: classification(1) + group(2) + sequence(3). Idempotent on gl_code.
-->
<changeSet id="0595_seed_loan_rate_register_gl_accounts" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<sql>
-- SHORT TERM LOAN (100300)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'OVERDRAFT', '100304', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100304');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'GOLD LOAN', '100305', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100305');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'AGRICULTURE LOAN', '100306', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100300' AND p.name = 'SHORT TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100306');
-- MEDIUM TERM LOAN (100400)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'TWO WHEELER LOAN', '100403', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100403');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'CAR LOAN (NEW)', '100404', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100404');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'USED CAR / VEHICLE LOAN', '100405', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100405');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'COMMERCIAL VEHICLE LOAN', '100406', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100406');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'BUSINESS TERM LOAN', '100407', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100407');
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'MACHINERY &amp; EQUIPMENT LOAN', '100408', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100400' AND p.name = 'MEDIUM TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100408');
-- LONG TERM LOAN (100500)
INSERT INTO acc_gl_account (name, gl_code, disabled, manual_journal_entries_allowed, account_usage, classification_enum, parent_id)
SELECT 'HOME REPAIR / RENOVATION LOAN', '100502', false, true, 1, 1, p.id FROM acc_gl_account p
WHERE p.gl_code = '100500' AND p.name = 'LONG TERM LOAN'
AND NOT EXISTS (SELECT 1 FROM acc_gl_account WHERE gl_code = '100502');
</sql>
</changeSet>
</databaseChangeLog>