Compare commits

...
Author SHA1 Message Date
sonaliandClaude Sonnet 5 2d10ec60f8 feat(loan-products): add Loans A/C extension fields (collateral, eligibility, repayment, rate source, prepayment)
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Stores the Loans A/C (EMI) wizard's fields that have no home in
Fineract's core loan-product schema on the same per-product satellite
used for Advances: collateral/guarantor rules, borrower eligibility,
disbursement, repayment mode, rate source / floating reset, and
part-prepayment/foreclosure. Columns are loan_-prefixed so they never
collide with the Advances columns on the shared table.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:50:47 +05:30
sonaliandClaude Sonnet 5 14973dfd5e feat(delinquency): add maker-checker for Delinquency Ranges and Buckets
Range/bucket create, update and delete now route through the standard
command-source approval pipeline, since a bucket drives the SMA/NPA
classification of every loan product linked to it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-10-12 15:50:29 +05:30
sonali a127710442 feat(loan-products): add product master table and GL account seeding for loan interest rate registe 2026-10-08 17:29:39 +05:30
sonali 919bfb5204 Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora-backend into feature/advanceLoanProduct 2026-10-07 15:31:57 +05:30
sonali 39e3fcec59 feat: add loan interest backend functionality
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
2026-10-07 15:29:18 +05:30
Sonali LikhitkarandClaude Sonnet 5.5 b726577031 Update default DB password in application.properties
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 16:15:03 +05:30
Sonali Likhitkar d1e94fd6a3 Add Fees-Penalty-Interest-Principal repayment transaction processor 2026-10-05 16:13:35 +05:30
Sonali Likhitkar 22c4403a14 feat(loan-product): add advance loan product ext entity, API and liquibase 2026-09-29 17:56:50 +05:30
45 changed files with 3656 additions and 6 deletions
@@ -157,6 +157,9 @@ public class CommandSourceService {
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_BUCKET".equalsIgnoreCase(commandSource.getEntityName())
|| "DELINQUENCY_RANGE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
@@ -2299,6 +2299,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createLoanBaseInterestRate() {
this.actionName = "CREATE";
this.entityName = "LOANBASEINTERESTRATE";
this.entityId = null;
this.href = "/loanproducts/baseinterestrates";
return this;
}
public CommandWrapperBuilder updateFixedDepositProduct(final Long productId) {
this.actionName = "UPDATE";
this.entityName = "FIXEDDEPOSITPRODUCT";
@@ -505,6 +505,7 @@ public class FineractProperties {
private FineractTransactionProcessorItemProperties heavensFamily;
private FineractTransactionProcessorItemProperties interestPrincipalPenaltiesFees;
private FineractTransactionProcessorItemProperties principalInterestPenaltiesFees;
private FineractTransactionProcessorItemProperties feesPenaltiesInterestPrincipal;
private FineractTransactionProcessorItemProperties rbiIndia;
private FineractTransactionProcessorItemProperties duePenaltyFeeInterestPrincipalInAdvancePrincipalPenaltyFeeInterest;
private FineractTransactionProcessorItemProperties duePenaltyInterestPrincipalFeeInAdvancePenaltyInterestPrincipalFee;
@@ -0,0 +1,189 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl;
import java.time.LocalDate;
import java.util.List;
import java.util.Set;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.portfolio.loanaccount.domain.LoanCharge;
import org.apache.fineract.portfolio.loanaccount.domain.LoanRepaymentScheduleInstallment;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransaction;
import org.apache.fineract.portfolio.loanaccount.domain.LoanTransactionToRepaymentScheduleMapping;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.AbstractLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.LoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.serialization.LoanChargeValidator;
import org.apache.fineract.portfolio.loanaccount.service.LoanBalanceService;
/**
* {@link LoanRepaymentScheduleTransactionProcessor} that pays off components in order of fees, penalties, interest,
* then principal.
*
* Other formulas exist on fineract where you can choose 'Declining-Balance Interest Recalculation' which simply means,
* recalculate the interest component based on the how much principal is outstanding at a point in time; but this isnt
* trying to model that option only the basic one for now.
*/
@SuppressWarnings("unused")
public class FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor extends AbstractLoanRepaymentScheduleTransactionProcessor {
public static final String STRATEGY_CODE = "fees-penalties-interest-principal-order-strategy";
public static final String STRATEGY_NAME = "Fees, Penalties, Interest, Principal Order";
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(final ExternalIdFactory externalIdFactory,
final LoanChargeValidator loanChargeValidator, final LoanBalanceService loanBalanceService) {
super(externalIdFactory, loanChargeValidator, loanBalanceService);
}
@Override
public String getCode() {
return STRATEGY_CODE;
}
@Override
public String getName() {
return STRATEGY_NAME;
}
/**
* For early/'in advance' repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsPaymentInAdvanceOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction, final Money paymentInAdvance,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, paymentInAdvance, transactionMappings,
charges);
}
/**
* For late repayments, pay off in the same way as on-time payments, interest first then principal.
*/
@Override
protected Money handleTransactionThatIsALateRepaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final List<LoanRepaymentScheduleInstallment> installments, final LoanTransaction loanTransaction,
final Money transactionAmountUnprocessed, List<LoanTransactionToRepaymentScheduleMapping> transactionMappings,
Set<LoanCharge> charges) {
return handleTransactionThatIsOnTimePaymentOfInstallment(currentInstallment, loanTransaction, transactionAmountUnprocessed,
transactionMappings, charges);
}
/**
* For normal on-time repayments, pays off interest first, then principal.
*/
@Override
protected Money handleTransactionThatIsOnTimePaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings, Set<LoanCharge> charges) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money interestPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money feeChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
Money penaltyChargesPortion = Money.zero(transactionAmountRemaining.getCurrency());
if (loanTransaction.isChargesWaiver()) {
penaltyChargesPortion = currentInstallment.waivePenaltyChargesComponent(transactionDate,
loanTransaction.getPenaltyChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
feeChargesPortion = currentInstallment.waiveFeeChargesComponent(transactionDate,
loanTransaction.getFeeChargesPortion(currency));
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
} else if (loanTransaction.isInterestWaiver()) {
interestPortion = currentInstallment.waiveInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else if (loanTransaction.isChargePayment()) {
if (loanTransaction.isPenaltyPayment()) {
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
} else {
feeChargesPortion = currentInstallment.payFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
penaltyChargesPortion = currentInstallment.payPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
interestPortion = currentInstallment.payInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
principalPortion = currentInstallment.payPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
}
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
@Override
protected Money handleRefundTransactionPaymentOfInstallment(final LoanRepaymentScheduleInstallment currentInstallment,
final LoanTransaction loanTransaction, final Money transactionAmountUnprocessed,
List<LoanTransactionToRepaymentScheduleMapping> transactionMappings) {
final LocalDate transactionDate = loanTransaction.getTransactionDate();
final MonetaryCurrency currency = transactionAmountUnprocessed.getCurrency();
Money transactionAmountRemaining = transactionAmountUnprocessed;
Money principalPortion = Money.zero(currency);
Money interestPortion = Money.zero(currency);
Money feeChargesPortion = Money.zero(currency);
Money penaltyChargesPortion = Money.zero(currency);
principalPortion = currentInstallment.unpayPrincipalComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(principalPortion);
if (transactionAmountRemaining.isGreaterThanZero()) {
interestPortion = currentInstallment.unpayInterestComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(interestPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
penaltyChargesPortion = currentInstallment.unpayPenaltyChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(penaltyChargesPortion);
}
if (transactionAmountRemaining.isGreaterThanZero()) {
feeChargesPortion = currentInstallment.unpayFeeChargesComponent(transactionDate, transactionAmountRemaining);
transactionAmountRemaining = transactionAmountRemaining.minus(feeChargesPortion);
}
loanTransaction.updateComponents(principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion);
if (principalPortion.plus(interestPortion).plus(feeChargesPortion).plus(penaltyChargesPortion).isGreaterThanZero()) {
transactionMappings.add(LoanTransactionToRepaymentScheduleMapping.createFrom(loanTransaction, currentInstallment,
principalPortion, interestPortion, feeChargesPortion, penaltyChargesPortion));
}
return transactionAmountRemaining;
}
}
@@ -0,0 +1,30 @@
/**
* 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.
*/
package org.apache.fineract.portfolio.loanaccount.starter;
import org.apache.fineract.infrastructure.core.condition.PropertiesCondition;
import org.apache.fineract.infrastructure.core.config.FineractProperties;
public class FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition extends PropertiesCondition {
@Override
protected boolean matches(FineractProperties properties) {
return properties.getLoan().getTransactionProcessor().getFeesPenaltiesInterestPrincipal().isEnabled();
}
}
@@ -225,6 +225,12 @@ public class LoanProductData implements Serializable {
private final LoanProductGuaranteeData productGuaranteeData;
private final Boolean accountMovesOutOfNPAOnlyOnArrearsCompletion;
private LoanProductConfigurableAttributes allowAttributeOverrides;
/**
* "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book), flat by their form field
* name. Set after construction (like {@link #allowAttributeOverrides}) so the edit form can repopulate.
*/
private Map<String, Object> advanceExtensions;
private final boolean syncExpectedWithDisbursementDate;
private final boolean isEqualAmortization;
private final BigDecimal fixedPrincipalPercentagePerInstallment;
@@ -1466,6 +1472,14 @@ public class LoanProductData implements Serializable {
this.allowAttributeOverrides = loanProductConfigurableAttributes;
}
public void setAdvanceExtensions(final Map<String, Object> advanceExtensions) {
this.advanceExtensions = advanceExtensions;
}
public Map<String, Object> getAdvanceExtensions() {
return this.advanceExtensions;
}
public boolean isIsLinkedToFloatingInterestRates() {
return this.isLinkedToFloatingInterestRates;
}
@@ -0,0 +1,471 @@
/**
* 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.FetchType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* "Advances A/C" product configuration that Fineract's loan-product schema has no home for
* (security &amp; margin, lien, deposit-linked interest, penal/NPA handling, renewal, monitoring,
* cheque book). It is a standalone satellite keyed by {@code loan_product_id} rather than a widening
* of {@code m_product_loan} or a new relation on {@link LoanProduct}: the product entity graph is
* loaded on the hot loan-origination path, so it is deliberately left untouched.
*
* Typed columns (not a JSON blob) so reports can filter on them and GL ids get a real FK.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "m_product_loan_advance_ext")
public class LoanProductAdvanceExt {
@Id
@Column(name = "loan_product_id")
private Long loanProductId;
/** Shared primary key, mapped read-only; {@link #loanProductId} is the writable side. */
@OneToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "loan_product_id", insertable = false, updatable = false)
private LoanProduct loanProduct;
// ---- classification -------------------------------------------------
@Column(name = "product_type_group")
private String productTypeGroup;
@Column(name = "product_type_sub_group")
private String productTypeSubGroup;
@Column(name = "gl_account_id")
private Long glAccountId;
@Column(name = "loan_product_category")
private String loanProductCategory;
@Column(name = "priority_sector")
private String prioritySector;
@Column(name = "cibil_account_type")
private String cibilAccountType;
@Column(name = "kfs_enabled")
private Boolean kfsEnabled;
@Column(name = "apr_enabled")
private Boolean aprEnabled;
// ---- facility -------------------------------------------------------
@Column(name = "facility_type")
private String facilityType;
// ---- security & margin ----------------------------------------------
@Column(name = "security_type")
private String securityType;
@Column(name = "charge_type")
private String chargeType;
@Column(name = "eligible_amount_basis")
private String eligibleAmountBasis;
@Column(name = "margin_pct")
private BigDecimal marginPct;
@Column(name = "ltv_pct")
private BigDecimal ltvPct;
@Column(name = "dp_method")
private String dpMethod;
// ---- lien -----------------------------------------------------------
@Column(name = "lien_required")
private Boolean lienRequired;
@Column(name = "lien_marking_stage")
private String lienMarkingStage;
@Column(name = "lien_certificate_required")
private Boolean lienCertificateRequired;
@Column(name = "lien_certificate_prefix")
private String lienCertificatePrefix;
@Column(name = "print_certificate_on_disbursement")
private Boolean printCertificateOnDisbursement;
// ---- interest (extras; rate bounds live on the core product) --------
@Column(name = "interest_basis")
private String interestBasis;
@Column(name = "spread_pct")
private BigDecimal spreadPct;
@Column(name = "interest_charged_on")
private String interestChargedOn;
@Column(name = "interest_posting_frequency")
private String interestPostingFrequency;
// ---- penal & NPA (extras; npaDays/grace/tolerance live on core) ------
@Column(name = "penal_applicable")
private Boolean penalApplicable;
@Column(name = "penal_rate_pct")
private BigDecimal penalRatePct;
@Column(name = "penal_applied_on")
private String penalAppliedOn;
@Column(name = "penal_grace_days")
private Integer penalGraceDays;
@Column(name = "npa_trigger")
private String npaTrigger;
@Column(name = "interest_after_npa")
private String interestAfterNpa;
@Column(name = "undebited_interest_gl_id")
private Long undebitedInterestGlId;
// ---- renewal & closure ----------------------------------------------
@Column(name = "renewal_allowed")
private Boolean renewalAllowed;
@Column(name = "renewal_period_months")
private Integer renewalPeriodMonths;
@Column(name = "renewal_grace_days")
private Integer renewalGraceDays;
@Column(name = "preclosure_allowed")
private String preclosureAllowed;
@Column(name = "lock_in_months")
private Integer lockInMonths;
@Column(name = "interest_on_preclosure")
private String interestOnPreclosure;
// ---- monitoring -----------------------------------------------------
@Column(name = "revaluation_freq_months")
private Integer revaluationFreqMonths;
@Column(name = "insurance_required")
private Boolean insuranceRequired;
@Column(name = "stock_statement_freq_months")
private Integer stockStatementFreqMonths;
// ---- cheque book ----------------------------------------------------
@Column(name = "cheque_book_enabled")
private Boolean chequeBookEnabled;
@Column(name = "cheque_type")
private String chequeType;
@Column(name = "default_cheque_book_size")
private Integer defaultChequeBookSize;
@Column(name = "pps_threshold")
private BigDecimal ppsThreshold;
@Column(name = "pps_mandatory_above")
private BigDecimal ppsMandatoryAbove;
// ---- share (co-op) --------------------------------------------------
@Column(name = "share_capital_gl_id")
private Long shareCapitalGlId;
// ---- security & lien extras -----------------------------------------
/** Pledgeable deposit product names, joined with "|". */
@Column(name = "eligible_deposit_products")
private String eligibleDepositProducts;
@Column(name = "lien_tenure_not_exceed_maturity")
private Boolean lienTenureNotExceedMaturity;
@Column(name = "lien_auto_release_on_closure")
private Boolean lienAutoReleaseOnClosure;
@Column(name = "lien_allow_multiple_deposits")
private Boolean lienAllowMultipleDeposits;
@Column(name = "lien_adjust_from_maturity")
private Boolean lienAdjustFromMaturity;
@Column(name = "lien_allow_third_party")
private Boolean lienAllowThirdParty;
@Column(name = "lien_allow_renewal_under_lien")
private Boolean lienAllowRenewalUnderLien;
// ---- limit & tenure extras ------------------------------------------
@Column(name = "repayment_type")
private String repaymentType;
@Column(name = "appropriation_order")
private String appropriationOrder;
@Column(name = "debit_balance_allow")
private Boolean debitBalanceAllow;
@Column(name = "allow_debit_after_due_date")
private Boolean allowDebitAfterDueDate;
@Column(name = "branch_level_account_type")
private Boolean branchLevelAccountType;
// ---- interest extras ------------------------------------------------
@Column(name = "interest_calc_method")
private String interestCalcMethod;
@Column(name = "interest_on_credit_balance")
private Boolean interestOnCreditBalance;
@Column(name = "interest_posting_day")
private String interestPostingDay;
// ---- NPA upgrade rules ----------------------------------------------
@Column(name = "npa_upgrade_only_all_arrears")
private Boolean npaUpgradeOnlyAllArrears;
@Column(name = "npa_keep_standard_if_deposit_covers")
private Boolean npaKeepStandardIfDepositCovers;
// ---- cheque book full config ----------------------------------------
/** Allowed book sizes (leaves), joined with "|". */
@Column(name = "cheque_book_sizes")
private String chequeBookSizes;
@Column(name = "cheque_max_books_outstanding")
private Integer chequeMaxBooksOutstanding;
@Column(name = "cheque_reissue_when_leaves")
private Integer chequeReissueWhenLeaves;
@Column(name = "cheque_first_book_free")
private Boolean chequeFirstBookFree;
@Column(name = "cheque_free_books_per_year")
private Integer chequeFreeBooksPerYear;
@Column(name = "cheque_name_printed")
private String chequeNamePrinted;
@Column(name = "cheque_print_description")
private String chequePrintDescription;
@Column(name = "cheque_stop_payment_allowed")
private Boolean chequeStopPaymentAllowed;
@Column(name = "cheque_maintain_stock_register")
private Boolean chequeMaintainStockRegister;
@Column(name = "cheque_allow_withdrawal_slip")
private Boolean chequeAllowWithdrawalSlip;
@Column(name = "cheque_block_npa")
private Boolean chequeBlockNpa;
@Column(name = "cheque_block_limit_expired")
private Boolean chequeBlockLimitExpired;
@Column(name = "cheque_block_over_dp")
private Boolean chequeBlockOverDp;
@Column(name = "cheque_block_frozen")
private Boolean chequeBlockFrozen;
@Column(name = "cheque_block_lien_released")
private Boolean chequeBlockLienReleased;
// ---- Loans A/C (EMI) wizard fields -------------------------------------
@Column(name = "loan_security_mandatory")
private String securityMandatory;
@Column(name = "loan_guarantors_required")
private Integer guarantorsRequired;
@Column(name = "loan_co_applicant")
private String coApplicant;
@Column(name = "loan_third_party_security")
private String thirdPartySecurity;
@Column(name = "loan_legal_opinion_required")
private Boolean legalOpinionRequired;
@Column(name = "loan_property_insurance_required")
private Boolean propertyInsuranceRequired;
@Column(name = "loan_register_charge")
private Boolean registerCharge;
@Column(name = "loan_release_security_on_closure")
private Boolean releaseSecurityOnClosure;
@Column(name = "loan_allow_multiple_securities")
private Boolean allowMultipleSecurities;
@Column(name = "loan_allow_third_party_deposit")
private Boolean allowThirdPartyDeposit;
@Column(name = "loan_min_age")
private Integer minAge;
@Column(name = "loan_max_age_at_maturity")
private Integer maxAgeAtMaturity;
@Column(name = "loan_min_cibil")
private Integer minCibil;
@Column(name = "loan_min_monthly_income")
private BigDecimal minMonthlyIncome;
@Column(name = "loan_max_foir")
private BigDecimal maxFoir;
@Column(name = "loan_max_loans_per_customer")
private Integer maxLoansPerCustomer;
@Column(name = "loan_disbursement_type")
private String disbursementType;
@Column(name = "loan_disbursement_mode")
private String disbursementMode;
@Column(name = "loan_undisbursed_interest")
private String undisbursedInterest;
@Column(name = "loan_pre_emi_interest")
private Boolean preEmiInterest;
@Column(name = "loan_emi_due_day")
private String emiDueDay;
@Column(name = "loan_holiday_rule")
private String holidayRule;
@Column(name = "loan_moratorium_months")
private Integer moratoriumMonths;
@Column(name = "loan_moratorium_interest")
private String moratoriumInterest;
@Column(name = "loan_variable_installments")
private Boolean variableInstallments;
@Column(name = "loan_repayment_mode")
private String repaymentMode;
@Column(name = "loan_linked_account_type")
private String linkedAccountType;
@Column(name = "loan_pdc_collection")
private String pdcCollection;
@Column(name = "loan_mandate_required")
private Boolean mandateRequired;
@Column(name = "loan_advance_emi")
private String advanceEmi;
@Column(name = "loan_rate_type")
private String rateType;
@Column(name = "loan_register_particular")
private String registerParticular;
@Column(name = "loan_apply_cibil_adjustment")
private Boolean applyCibilAdjustment;
@Column(name = "loan_rate_resolved_at")
private String rateResolvedAt;
@Column(name = "loan_calc_period")
private String calcPeriod;
@Column(name = "loan_interest_starts_from")
private String interestStartsFrom;
@Column(name = "loan_compounding")
private String compounding;
@Column(name = "loan_benchmark")
private String benchmark;
@Column(name = "loan_reset_frequency")
private String resetFrequency;
@Column(name = "loan_reset_change")
private String resetChange;
@Column(name = "loan_part_prepayment_allowed")
private Boolean partPrepaymentAllowed;
@Column(name = "loan_min_part_payment")
private BigDecimal minPartPayment;
@Column(name = "loan_allowed_on")
private String allowedOn;
@Column(name = "loan_part_payments_per_year")
private String partPaymentsPerYear;
@Column(name = "loan_part_prepayment_mode")
private String partPrepaymentMode;
@Column(name = "loan_foreclosure_floating_individual")
private BigDecimal foreclosureFloatingIndividual;
@Column(name = "loan_foreclosure_fixed_individual")
private BigDecimal foreclosureFixedIndividual;
@Column(name = "loan_foreclosure_non_individual")
private BigDecimal foreclosureNonIndividual;
@Column(name = "loan_foreclosure_takeover")
private BigDecimal foreclosureTakeover;
@Column(name = "loan_foreclosure_quote_valid_days")
private Integer foreclosureQuoteValidDays;
@Column(name = "loan_auto_close_on_zero_balance")
private Boolean autoCloseOnZeroBalance;
@Column(name = "loan_noc_letter")
private String nocLetter;
@Column(name = "loan_restructuring")
private String restructuring;
@Column(name = "loan_release_collateral_on_closure")
private Boolean releaseCollateralOnClosure;
@Column(name = "loan_top_up_allowed")
private Boolean topUpAllowed;
@Column(name = "loan_balance_transfer_out_allowed")
private Boolean balanceTransferOutAllowed;
public LoanProductAdvanceExt(final LoanProduct loanProduct) {
this.loanProduct = loanProduct;
this.loanProductId = loanProduct.getId();
}
}
@@ -0,0 +1,16 @@
/**
* 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.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanProductAdvanceExtRepository extends JpaRepository<LoanProductAdvanceExt, Long> {
Optional<LoanProductAdvanceExt> findByLoanProductId(Long loanProductId);
}
@@ -0,0 +1,76 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One CIBIL/credit-score band of a rate register version — e.g. {601..650} tier "Fair" adds +1.00%.
* Applied on top of the chart cell at loan sanction. Typically 5 rows per version; ordered on reads
* by {@code displayOrder}.
*/
@Entity
@Table(name = "m_loan_rate_register_cibil_slab")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterCibilSlab {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "effective_id", nullable = false)
private Long effectiveId;
@Column(name = "score_from", nullable = false)
private Integer scoreFrom;
@Column(name = "score_to", nullable = false)
private Integer scoreTo;
@Column(name = "tier", length = 30, nullable = false)
private String tier;
@Column(name = "slab_type", length = 20, nullable = false)
private String slabType;
@Column(name = "adjustment_pct", nullable = false)
private BigDecimal adjustmentPct;
@Column(name = "display_order", nullable = false)
private Integer displayOrder;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterCibilSlab create(final Long effectiveId, final Integer scoreFrom, final Integer scoreTo, final String tier,
final String slabType, final BigDecimal adjustmentPct, final Integer displayOrder) {
final LoanRateRegisterCibilSlab s = new LoanRateRegisterCibilSlab();
s.effectiveId = effectiveId;
s.scoreFrom = scoreFrom;
s.scoreTo = scoreTo;
s.tier = tier;
s.slabType = slabType != null ? slabType : "Flat";
s.adjustmentPct = adjustmentPct;
s.displayOrder = displayOrder;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterCibilSlabRepository extends JpaRepository<LoanRateRegisterCibilSlab, Long> {
/** CIBIL bands for a version, ordered as the UI shows them (ascending score bands). */
List<LoanRateRegisterCibilSlab> findByEffectiveIdOrderByDisplayOrderAsc(Long effectiveId);
}
@@ -0,0 +1,94 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.time.LocalDate;
import java.time.LocalDateTime;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.service.DateUtils;
/**
* One effective (versioned) copy of the central Loan Interest Rate Register. Mirrors the
* {@code FdBaseInterestRateEffective} versioning model: each approved save creates a new row with
* {@code isActive=true} and deactivates the previous active row ({@code isActive=false,
* effectiveToDate=today}). Reads for "current active" filter on {@code isActive=true}.
*
* The 34-product × 10-slab rate matrix for this version lives in {@link LoanRateRegisterSlabHistory};
* the CIBIL/credit-score bands live in {@link LoanRateRegisterCibilSlab}, both keyed by this row's
* id and cascade-deleted with it.
*/
@Entity
@Table(name = "m_loan_rate_register_effective")
@Getter
@Setter
@NoArgsConstructor
public class LoanRateRegisterEffective {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", length = 200, nullable = false)
private String name;
@Column(name = "effective_from_date", nullable = false)
private LocalDate effectiveFromDate;
@Column(name = "effective_to_date")
private LocalDate effectiveToDate;
@Column(name = "currency_code", length = 3, nullable = false)
private String currencyCode;
@Column(name = "is_active", nullable = false)
private Boolean isActive;
@Column(name = "fallback_no_cibil_slab", nullable = false)
private Integer fallbackNoCibilSlab;
@Column(name = "fallback_below_min", length = 20, nullable = false)
private String fallbackBelowMin;
@Column(name = "note", length = 500)
private String note;
@Column(name = "created_by_id")
private Long createdById;
@Column(name = "created_date")
private LocalDateTime createdDate;
public static LoanRateRegisterEffective create(final String name, final LocalDate effectiveFromDate, final String currencyCode,
final Integer fallbackNoCibilSlab, final String fallbackBelowMin, final String note, final Long createdById) {
final LoanRateRegisterEffective e = new LoanRateRegisterEffective();
e.name = name;
e.effectiveFromDate = effectiveFromDate;
e.currencyCode = currencyCode != null ? currencyCode : "INR";
e.isActive = true;
e.fallbackNoCibilSlab = fallbackNoCibilSlab != null ? fallbackNoCibilSlab : 2;
e.fallbackBelowMin = fallbackBelowMin != null ? fallbackBelowMin : "block";
e.note = note;
e.createdById = createdById;
e.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return e;
}
/** Flip this version to inactive and close its effective window. Called when a newer version is approved. */
public void deactivate(final LocalDate toDate) {
this.isActive = false;
this.effectiveToDate = toDate;
}
}
@@ -0,0 +1,24 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterEffectiveRepository extends JpaRepository<LoanRateRegisterEffective, Long> {
/** All active versions, newest-effective first. At most one row in a healthy system. */
List<LoanRateRegisterEffective> findByIsActiveTrueOrderByEffectiveFromDateDesc();
/** The current active version, if any. */
Optional<LoanRateRegisterEffective> findFirstByIsActiveTrueOrderByEffectiveFromDateDesc();
/** Version history list — all versions, newest first. */
List<LoanRateRegisterEffective> findAllByOrderByEffectiveFromDateDesc();
}
@@ -0,0 +1,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();
}
@@ -0,0 +1,98 @@
/**
* 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 ----------------------------------------------
/** 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;
@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 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;
s.slabLabel = slabLabel;
s.amountUpto = amountUpto;
s.rateValue = rateValue;
s.rateFormula = rateFormula;
s.isFormula = isFormula;
s.isNa = isNa;
s.createdDate = LocalDateTime.now(DateUtils.getDateTimeZoneOfTenant());
return s;
}
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoanRateRegisterSlabHistoryRepository extends JpaRepository<LoanRateRegisterSlabHistory, Long> {
/** All cells of a version, ordered by product then by amount-slab. Rebuilds the full matrix in order. */
List<LoanRateRegisterSlabHistory> findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(Long effectiveId);
}
@@ -27,6 +27,7 @@ import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.imp
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenFeeIntPriInAdvancePriPenFeeIntLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.DuePenIntPriFeeInAdvancePenIntPriFeeLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.EarlyPaymentLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.FineractStyleLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.HeavensFamilyLoanRepaymentScheduleTransactionProcessor;
import org.apache.fineract.portfolio.loanaccount.domain.transactionprocessor.impl.InterestPrincipalPenaltyFeesOrderLoanRepaymentScheduleTransactionProcessor;
@@ -98,6 +99,15 @@ public class LoanAccountAutoStarter {
loanBalanceService);
}
@Bean
@Conditional(FeesPenaltiesInterestPrincipalLoanRepaymentScheduleTransactionProcessorCondition.class)
public FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor feesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(
final ExternalIdFactory externalIdFactory, final LoanChargeValidator loanChargeValidator,
final LoanBalanceService loanBalanceService) {
return new FeesPenaltyInterestPrincipalOrderLoanRepaymentScheduleTransactionProcessor(externalIdFactory, loanChargeValidator,
loanBalanceService);
}
@Bean
@Conditional(RBIIndiaLoanRepaymentScheduleTransactionProcessorCondition.class)
public RBILoanRepaymentScheduleTransactionProcessor rbiLoanRepaymentScheduleTransactionProcessor(
@@ -37,10 +37,15 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.MultivaluedMap;
import jakarta.ws.rs.core.UriInfo;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@@ -58,6 +63,7 @@ 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.codes.data.CodeValueData;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.codes.service.CodeValueReadPlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.ApiFacingEnum;
@@ -107,6 +113,7 @@ import org.apache.fineract.portfolio.loanproduct.exception.LoanProductNotFoundEx
import org.apache.fineract.portfolio.loanproduct.productmix.data.ProductMixData;
import org.apache.fineract.portfolio.loanproduct.productmix.service.ProductMixReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.paymenttype.data.PaymentTypeData;
import org.apache.fineract.portfolio.paymenttype.service.PaymentTypeReadService;
@@ -142,7 +149,8 @@ public class LoanProductsApiResource {
LoanProductConstants.CAPITALIZED_INCOME_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.CAPITALIZED_INCOME_STRATEGY_PARAM_NAME, LoanProductConstants.CAPITALIZED_INCOME_TYPE_PARAM_NAME,
LoanProductConstants.ENABLE_BUY_DOWN_FEE_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_CALCULATION_TYPE_PARAM_NAME,
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME));
LoanProductConstants.BUY_DOWN_FEE_STRATEGY_PARAM_NAME, LoanProductConstants.BUY_DOWN_FEE_INCOME_TYPE_PARAM_NAME,
"advanceExtensions"));
private static final Set<String> PRODUCT_MIX_DATA_PARAMETERS = new HashSet<>(
Arrays.asList("productId", "productName", "restrictedProducts", "allowedProducts", "productOptions"));
@@ -153,6 +161,7 @@ public class LoanProductsApiResource {
private final PlatformSecurityContext context;
private final LoanProductReadPlatformService loanProductReadPlatformService;
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final ChargeReadPlatformService chargeReadPlatformService;
private final CurrencyReadPlatformService currencyReadPlatformService;
private final FundReadPlatformService fundReadPlatformService;
@@ -219,11 +228,54 @@ public class LoanProductsApiResource {
return this.productMixDataApiJsonSerializer.serialize(settings, productMixes, PRODUCT_MIX_DATA_PARAMETERS);
}
final Collection<LoanProductData> products = this.loanProductReadPlatformService.retrieveAllLoanProducts();
final Collection<LoanProductData> products = enrichAndFilterProducts(
this.loanProductReadPlatformService.retrieveAllLoanProducts(), uriInfo);
return this.toApiJsonSerializer.serialize(settings, products, LOAN_PRODUCT_DATA_PARAMETERS);
}
/**
* Adds each product's category and type ("Advances A/C" or "Loans A/C" and its sub type) to the list, and applies
* the optional filters {@code activeOnly}, {@code productTypeGroup} and {@code productTypeSubGroup}. With none of
* them given the list holds the same products as before, each now carrying its classification.
*/
private Collection<LoanProductData> enrichAndFilterProducts(final Collection<LoanProductData> products, final UriInfo uriInfo) {
final MultivaluedMap<String, String> query = uriInfo.getQueryParameters();
final boolean activeOnly = Boolean.parseBoolean(query.getFirst("activeOnly"));
final String group = trimToNull(query.getFirst("productTypeGroup"));
final String subGroup = trimToNull(query.getFirst("productTypeSubGroup"));
final Map<Long, Map<String, Object>> extensions = this.loanProductAdvanceExtService.retrieveAllAsMap();
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<LoanProductData> result = new ArrayList<>();
for (final LoanProductData product : products) {
final Map<String, Object> ext = extensions.getOrDefault(product.getId(), Collections.emptyMap());
if (activeOnly && !isActiveOn(product, today)) {
continue;
}
if (group != null && !group.equalsIgnoreCase(String.valueOf(ext.get("productTypeGroup")))) {
continue;
}
if (subGroup != null && !subGroup.equalsIgnoreCase(String.valueOf(ext.get("productTypeSubGroup")))) {
continue;
}
product.setAdvanceExtensions(new LinkedHashMap<>(ext));
result.add(product);
}
return result;
}
/** A product is active from its start date until its close date; either end may be open. */
private static boolean isActiveOn(final LoanProductData product, final LocalDate date) {
final LocalDate start = product.getStartDate();
final LocalDate close = product.getCloseDate();
return (start == null || !start.isAfter(date)) && (close == null || !close.isBefore(date));
}
private static String trimToNull(final String value) {
return value == null || value.isBlank() ? null : value.trim();
}
@GET
@Path("template")
@Consumes({ MediaType.APPLICATION_JSON })
@@ -384,6 +436,9 @@ public class LoanProductsApiResource {
if (settings.isTemplate()) {
loanProduct = handleTemplate(loanProduct);
}
// "Advances A/C" satellite fields, so the edit form can repopulate. Set last: the accounting and template
// steps above return fresh LoanProductData copies that would otherwise drop it. Empty map for a standard product.
loanProduct.setAdvanceExtensions(this.loanProductAdvanceExtService.retrieveAsMap(productId));
return this.toApiJsonSerializer.serialize(settings, loanProduct, LOAN_PRODUCT_DATA_PARAMETERS);
}
@@ -0,0 +1,134 @@
/**
* 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_LOANBASEINTERESTRATE} (maker) and {@code CREATE_LOANBASEINTERESTRATE_CHECKER}.
*/
@Path("/v1/loanproducts/baseinterestrates")
@Component
@RequiredArgsConstructor
public class LoanProductsBaseInterestRatesApiResource {
private static final String RESOURCE_NAME_FOR_PERMISSIONS = "LOANPRODUCT";
private final PlatformSecurityContext context;
private final LoanRateRegisterReadPlatformService readService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final Gson gson = new GsonBuilder()
.registerTypeAdapter(LocalDate.class,
(JsonSerializer<LocalDate>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.toString()))
.registerTypeAdapter(LocalDateTime.class,
(JsonSerializer<LocalDateTime>) (src, typeOfSrc, ctx) -> new JsonPrimitive(src.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)))
.create();
/** Full Version History — all effective versions, newest first. */
@GET
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get all Loan Interest Rate Register versions", description = "Returns the full history of rate register versions, newest first.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final List<LoanRateRegisterData> data = this.readService.retrieveAll();
return this.gson.toJson(data);
}
/** 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")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get the active Loan Interest Rate Register", description = "Returns the version flagged isActive=true, or null when none has been approved yet.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK") })
public String retrieveActive() {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final Optional<LoanRateRegisterData> data = this.readService.retrieveActive();
return this.gson.toJson(data.orElse(null));
}
/** One specific version by id — used by the Compare screen for the "will be deactivated" baseline. */
@GET
@Path("{id}")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Get a specific rate register version", description = "Returns the full chart + CIBIL bands for the given version id.")
@ApiResponses({ @ApiResponse(responseCode = "200", description = "OK"), @ApiResponse(responseCode = "404", description = "Not found") })
public String retrieveById(@PathParam("id") final Long id) {
this.context.authenticatedUser().validateHasReadPermission(RESOURCE_NAME_FOR_PERMISSIONS);
final Optional<LoanRateRegisterData> data = this.readService.retrieveById(id);
return this.gson.toJson(data.orElse(null));
}
/**
* Submit a new version. If {@code CREATE_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.
*/
@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()
.createLoanBaseInterestRate()
.withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
}
}
@@ -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.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;
/**
* 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
public static class SlabRequest {
private Integer productSrNo;
private String productName;
private Integer slabOrder;
private String slabLabel;
private BigDecimal amountUpto;
private BigDecimal rateValue;
private String rateFormula;
private Boolean isFormula;
private Boolean isNa;
}
@Getter
@Setter
@NoArgsConstructor
public static class CibilSlabRequest {
private Integer scoreFrom;
private Integer scoreTo;
private String tier;
private String slabType;
private BigDecimal adjustmentPct;
private Integer displayOrder;
}
}
@@ -0,0 +1,28 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/**
* One CIBIL/credit-score band of a rate register version, used in the response payload.
*/
@Getter
@Builder
public class LoanRateRegisterCibilSlabData {
private Long id;
private Integer scoreFrom;
private Integer scoreTo;
private String tier;
private String slabType;
private BigDecimal adjustmentPct;
private Integer displayOrder;
}
@@ -0,0 +1,38 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
import lombok.Getter;
/**
* Response DTO for one effective version of the Loan Interest Rate Register. Header fields +
* nested chart cells + CIBIL bands.
*/
@Getter
@Builder
public class LoanRateRegisterData {
private Long id;
private String name;
private LocalDate effectiveFromDate;
private LocalDate effectiveToDate;
private String currencyCode;
private Boolean isActive;
private Integer fallbackNoCibilSlab;
private String fallbackBelowMin;
private String note;
private Long createdById;
private LocalDateTime createdDate;
private List<LoanRateRegisterSlabData> slabs;
private List<LoanRateRegisterCibilSlabData> cibilSlabs;
}
@@ -0,0 +1,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;
}
@@ -0,0 +1,33 @@
/**
* 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 Long productId;
private Integer productSrNo;
private String productName;
private Integer slabOrder;
private String slabLabel;
private BigDecimal amountUpto;
private BigDecimal rateValue;
private String rateFormula;
private Boolean isFormula;
private Boolean isNa;
}
@@ -0,0 +1,37 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.loanproduct.service.LoanRateRegisterWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Dispatched by Fineract when a {@code 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.createLoanBaseInterestRate()}
* and the permission codes seeded by {@code 0581_enable_loan_rate_register_maker_checker.xml}.
*/
@Service
@CommandType(entity = "LOANBASEINTERESTRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateLoanBaseInterestRateCommandHandler implements NewCommandSourceHandler {
private final LoanRateRegisterWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.saveRateRegister(command);
}
}
@@ -72,6 +72,7 @@ import org.apache.fineract.portfolio.loanproduct.domain.InterestRecalculationCom
import org.apache.fineract.portfolio.loanproduct.domain.LoanPreCloseInterestCalculationStrategy;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductPaymentAllocationRule;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductValueConditionType;
import org.apache.fineract.portfolio.loanproduct.domain.LoanRescheduleStrategyMethod;
import org.apache.fineract.portfolio.loanproduct.domain.LoanSupportedInterestRefundTypes;
@@ -212,6 +213,13 @@ public final class LoanProductDataValidator {
LoanProductConstants.ALLOW_FULL_TERM_FOR_TRANCHE_PARAM_NAME //
));
static {
// "Advances A/C" satellite params are persisted by LoanProductAdvanceExtService, which owns the
// authoritative list of their names — added here rather than restated so the two cannot drift, and so
// the unsupported-parameter check keeps rejecting genuine typos.
SUPPORTED_PARAMETERS.addAll(Arrays.asList(LoanProductAdvanceExtService.ADVANCE_PARAMETERS));
}
private static final String[] SUPPORTED_LOAN_CONFIGURABLE_ATTRIBUTES = { LoanProductConstants.amortizationTypeParamName,
LoanProductConstants.interestTypeParamName, LoanProductConstants.transactionProcessingStrategyCodeParamName,
LoanProductConstants.interestCalculationPeriodTypeParamName, LoanProductConstants.inArrearsToleranceParamName,
@@ -0,0 +1,164 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.serialization;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.CibilSlabRequest;
import org.apache.fineract.portfolio.loanproduct.api.LoanRateRegisterSaveRequest.SlabRequest;
import org.springframework.stereotype.Component;
/**
* Validates a {@link LoanRateRegisterSaveRequest} before the write service touches the DB. Keeps
* the write service free of guard clauses and surfaces every error up front rather than failing on
* the first one — Fineract convention.
*/
@Component
public class LoanRateRegisterDataValidator {
private static final String RESOURCE_NAME = "loanRateRegister";
public void validate(final LoanRateRegisterSaveRequest req) {
final List<ApiParameterError> errors = new ArrayList<>();
if (req == null) {
errors.add(error("payload", "payload.required", "Request body is required"));
raise(errors);
}
// Header fields
if (isBlank(req.getName())) {
errors.add(error("name", "name.required", "Register name is required"));
} else if (req.getName().length() > 200) {
errors.add(error("name", "name.exceeds.max.length", "Register name must be at most 200 characters"));
}
if (req.getFallbackNoCibilSlab() != null && req.getFallbackNoCibilSlab() < 1) {
errors.add(error("fallbackNoCibilSlab", "fallback.no.cibil.slab.invalid", "Fallback slab pointer must be >= 1"));
}
if (req.getFallbackBelowMin() != null && !req.getFallbackBelowMin().equals("use-min-slab")
&& !req.getFallbackBelowMin().equals("block")) {
errors.add(error("fallbackBelowMin", "fallback.below.min.invalid",
"fallbackBelowMin must be 'use-min-slab' or 'block'"));
}
// Chart cells
final List<SlabRequest> slabs = req.getSlabs();
if (slabs == null || slabs.isEmpty()) {
errors.add(error("slabs", "slabs.required", "At least one rate chart cell is required"));
} else {
for (int i = 0; i < slabs.size(); i++) {
validateSlab(slabs.get(i), i, errors);
}
}
// CIBIL bands
final List<CibilSlabRequest> cibil = req.getCibilSlabs();
if (cibil == null || cibil.isEmpty()) {
errors.add(error("cibilSlabs", "cibilSlabs.required", "At least one CIBIL slab is required"));
} else {
validateCibilCoverage(cibil, errors);
}
raise(errors);
}
private void validateSlab(final SlabRequest s, final int idx, final List<ApiParameterError> errors) {
final String prefix = "slabs[" + idx + "]";
if (s.getProductSrNo() == null) {
errors.add(error(prefix + ".productSrNo", "productSrNo.required", "productSrNo is required"));
}
if (isBlank(s.getProductName())) {
errors.add(error(prefix + ".productName", "productName.required", "productName is required"));
}
if (s.getSlabOrder() == null) {
errors.add(error(prefix + ".slabOrder", "slabOrder.required", "slabOrder is required"));
}
if (isBlank(s.getSlabLabel())) {
errors.add(error(prefix + ".slabLabel", "slabLabel.required", "slabLabel is required"));
}
// Exactly one of: rateValue populated, rateFormula populated, isNa=true. Both-null-and-not-NA is invalid.
final boolean hasValue = s.getRateValue() != null;
final boolean hasFormula = s.getRateFormula() != null && !s.getRateFormula().isBlank();
final boolean na = Boolean.TRUE.equals(s.getIsNa());
final int populated = (hasValue ? 1 : 0) + (hasFormula ? 1 : 0) + (na ? 1 : 0);
if (populated == 0) {
errors.add(error(prefix, "cell.empty",
"Cell must have a rateValue, a rateFormula, or isNa=true"));
} else if (populated > 1) {
errors.add(error(prefix, "cell.ambiguous",
"Cell must have exactly one of rateValue, rateFormula, or isNa"));
}
if (hasFormula && !Boolean.TRUE.equals(s.getIsFormula())) {
errors.add(error(prefix + ".isFormula", "isFormula.mismatch",
"isFormula must be true when rateFormula is set"));
}
}
/** CIBIL bands must cover 0..900 contiguously and must not overlap. */
private void validateCibilCoverage(final List<CibilSlabRequest> cibil, final List<ApiParameterError> errors) {
final List<CibilSlabRequest> sorted = new ArrayList<>(cibil);
sorted.sort((a, b) -> Integer.compare(nz(a.getScoreFrom()), nz(b.getScoreFrom())));
final Set<Integer> seenOrders = new TreeSet<>();
int expectedNextFrom = 0;
for (int i = 0; i < sorted.size(); i++) {
final CibilSlabRequest c = sorted.get(i);
final String prefix = "cibilSlabs[" + i + "]";
if (c.getScoreFrom() == null || c.getScoreTo() == null) {
errors.add(error(prefix, "cibil.range.required", "scoreFrom and scoreTo are required"));
continue;
}
if (c.getScoreFrom() > c.getScoreTo()) {
errors.add(error(prefix, "cibil.range.inverted", "scoreFrom must be <= scoreTo"));
}
if (c.getScoreFrom() != expectedNextFrom) {
errors.add(error(prefix, "cibil.range.gap_or_overlap",
"CIBIL bands must be contiguous starting from 0 (expected " + expectedNextFrom
+ ", got " + c.getScoreFrom() + ")"));
}
if (isBlank(c.getTier())) {
errors.add(error(prefix + ".tier", "tier.required", "tier is required"));
}
if (c.getAdjustmentPct() == null) {
errors.add(error(prefix + ".adjustmentPct", "adjustmentPct.required", "adjustmentPct is required"));
}
if (c.getDisplayOrder() == null || !seenOrders.add(c.getDisplayOrder())) {
errors.add(error(prefix + ".displayOrder", "displayOrder.invalid",
"displayOrder is required and must be unique"));
}
expectedNextFrom = c.getScoreTo() + 1;
}
if (!sorted.isEmpty() && expectedNextFrom <= 900) {
errors.add(error("cibilSlabs", "cibil.coverage.incomplete",
"CIBIL bands must cover up to 900 (last to was " + (expectedNextFrom - 1) + ")"));
}
}
private static ApiParameterError error(final String field, final String code, final String msg) {
return ApiParameterError.parameterError("validation." + RESOURCE_NAME + "." + code, msg, field);
}
private static void raise(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg." + RESOURCE_NAME + ".failed",
"Validation errors exist.", errors);
}
}
private static boolean isBlank(final String s) {
return s == null || s.isBlank();
}
private static int nz(final Integer n) {
return n != null ? n : 0;
}
}
@@ -0,0 +1,514 @@
/**
* 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.JsonElement;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProduct;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExt;
import org.apache.fineract.portfolio.loanproduct.domain.LoanProductAdvanceExtRepository;
import org.springframework.stereotype.Service;
/**
* Persists and reads the "Advances A/C" satellite ({@link LoanProductAdvanceExt}).
*
* Only touched for an Advances product: a standard loan product carries none of {@link #ADVANCE_PARAMETERS},
* so {@link #saveOrUpdate} early-returns and never writes an empty row. Called from inside the loan-product
* create/update transaction, so a failure here rolls the whole product back.
*/
@Service
@RequiredArgsConstructor
public class LoanProductAdvanceExtService {
private final LoanProductAdvanceExtRepository repository;
/**
* Every Advance parameter name. Shared with {@code LoanProductDataValidator} (added to its
* SUPPORTED_PARAMETERS) so the two can never disagree about what is accepted. These are the
* satellite-only fields; core fields (principal, rate, tenure, npaDays, delinquencyBucketId,
* graceOnArrearsAgeing, inArrearsTolerance, accountingRule...) are handled by the standard product.
*/
public static final String[] ADVANCE_PARAMETERS = { "productTypeGroup", "productTypeSubGroup", "glAccount",
"loanProductCategory", "prioritySector", "cibilAccountType", "kfsEnabled", "aprEnabled", "facilityType",
"securityType", "chargeType", "eligibleAmountBasis", "margin", "ltv", "dpMethod", "lienRequired",
"lienMarkingStage", "lienCertificateRequired", "lienCertificatePrefix", "printCertificateOnDisbursement",
"interestBasis", "spread", "interestChargedOn", "interestPostingFrequency", "penalApplicable", "penalRate",
"penalAppliedOn", "penalGraceDays", "npaTrigger", "interestAfterNpa", "undebitedInterestGlId",
"renewalAllowed", "renewalPeriodMonths", "renewalGraceDays", "preclosureAllowed", "lockInMonths",
"interestOnPreclosure", "revaluationFreqMonths", "insuranceRequired", "stockStatementFreqMonths",
"chequeBookEnabled", "chequeType", "defaultChequeBookSize", "ppsThreshold", "ppsMandatoryAbove",
"shareCapitalGl",
// security & lien extras
"eligibleDepositProducts", "lienTenureNotExceedMaturity", "lienAutoReleaseOnClosure",
"lienAllowMultipleDeposits", "lienAdjustFromMaturity", "lienAllowThirdParty", "lienAllowRenewalUnderLien",
// limit & tenure extras
"repaymentType", "appropriationOrder", "debitBalanceAllow", "allowDebitAfterDueDate", "branchLevelAccountType",
// interest extras
"interestCalcMethod", "interestOnCreditBalance", "interestPostingDay",
// NPA upgrade rules
"npaUpgradeOnlyAllArrears", "npaKeepStandardIfDepositCovers",
// cheque book full config
"chequeBookSizes", "chequeMaxBooksOutstanding", "chequeReissueWhenLeaves", "chequeFirstBookFree",
"chequeFreeBooksPerYear", "chequeNamePrinted", "chequePrintDescription", "chequeStopPaymentAllowed",
"chequeMaintainStockRegister", "chequeAllowWithdrawalSlip", "chequeBlockNpa", "chequeBlockLimitExpired",
"chequeBlockOverDp", "chequeBlockFrozen", "chequeBlockLienReleased",
// Loans A/C (EMI) wizard fields
"securityMandatory", "guarantorsRequired", "coApplicant", "thirdPartySecurity",
"legalOpinionRequired", "propertyInsuranceRequired", "registerCharge", "releaseSecurityOnClosure",
"allowMultipleSecurities", "allowThirdPartyDeposit", "minAge", "maxAgeAtMaturity",
"minCibil", "minMonthlyIncome", "maxFoir", "maxLoansPerCustomer",
"disbursementType", "disbursementMode", "undisbursedInterest", "preEmiInterest",
"emiDueDay", "holidayRule", "moratoriumMonths", "moratoriumInterest",
"variableInstallments", "repaymentMode", "linkedAccountType", "pdcCollection",
"mandateRequired", "advanceEmi", "rateType", "registerParticular",
"applyCibilAdjustment", "rateResolvedAt", "calcPeriod", "interestStartsFrom",
"compounding", "benchmark", "resetFrequency", "resetChange",
"partPrepaymentAllowed", "minPartPayment", "allowedOn", "partPaymentsPerYear",
"partPrepaymentMode", "foreclosureFloatingIndividual", "foreclosureFixedIndividual", "foreclosureNonIndividual",
"foreclosureTakeover", "foreclosureQuoteValidDays", "autoCloseOnZeroBalance", "nocLetter",
"restructuring", "releaseCollateralOnClosure", "topUpAllowed", "balanceTransferOutAllowed" };
/**
* Insert or partial-update the satellite for this product. Returns the fields that actually changed so the
* caller can fold them into the loan-product audit {@code changes} map.
*/
public Map<String, Object> saveOrUpdate(final LoanProduct loanProduct, final JsonCommand command) {
final Map<String, Object> changes = new LinkedHashMap<>();
if (!hasAnyAdvanceParameter(command)) {
return changes;
}
final LoanProductAdvanceExt ext = this.repository.findByLoanProductId(loanProduct.getId())
.orElseGet(() -> new LoanProductAdvanceExt(loanProduct));
applyScalarFields(ext, command, changes);
this.repository.save(ext);
return changes;
}
public Map<String, Object> retrieveAsMap(final Long loanProductId) {
return this.repository.findByLoanProductId(loanProductId).map(LoanProductAdvanceExtService::toMap)
.orElseGet(LinkedHashMap::new);
}
/**
* Every product's satellite in one query, keyed by loan product id. Used by the product list so a screen can
* show and filter products by category and type without one extra call per product.
*/
public Map<Long, Map<String, Object>> retrieveAllAsMap() {
final Map<Long, Map<String, Object>> byProduct = new LinkedHashMap<>();
for (final LoanProductAdvanceExt ext : this.repository.findAll()) {
byProduct.put(ext.getLoanProductId(), toMap(ext));
}
return byProduct;
}
private boolean hasAnyAdvanceParameter(final JsonCommand command) {
for (final String param : ADVANCE_PARAMETERS) {
if (command.parameterExists(param)) {
return true;
}
}
return false;
}
private void applyScalarFields(final LoanProductAdvanceExt ext, final JsonCommand command,
final Map<String, Object> changes) {
setString(command, "productTypeGroup", ext::getProductTypeGroup, ext::setProductTypeGroup, changes);
setString(command, "productTypeSubGroup", ext::getProductTypeSubGroup, ext::setProductTypeSubGroup, changes);
setLong(command, "glAccount", ext::getGlAccountId, ext::setGlAccountId, changes);
setString(command, "loanProductCategory", ext::getLoanProductCategory, ext::setLoanProductCategory, changes);
setString(command, "prioritySector", ext::getPrioritySector, ext::setPrioritySector, changes);
setString(command, "cibilAccountType", ext::getCibilAccountType, ext::setCibilAccountType, changes);
setBoolean(command, "kfsEnabled", ext::getKfsEnabled, ext::setKfsEnabled, changes);
setBoolean(command, "aprEnabled", ext::getAprEnabled, ext::setAprEnabled, changes);
setString(command, "facilityType", ext::getFacilityType, ext::setFacilityType, changes);
setString(command, "securityType", ext::getSecurityType, ext::setSecurityType, changes);
setString(command, "chargeType", ext::getChargeType, ext::setChargeType, changes);
setString(command, "eligibleAmountBasis", ext::getEligibleAmountBasis, ext::setEligibleAmountBasis, changes);
setDecimal(command, "margin", ext::getMarginPct, ext::setMarginPct, changes);
setDecimal(command, "ltv", ext::getLtvPct, ext::setLtvPct, changes);
setString(command, "dpMethod", ext::getDpMethod, ext::setDpMethod, changes);
setBoolean(command, "lienRequired", ext::getLienRequired, ext::setLienRequired, changes);
setString(command, "lienMarkingStage", ext::getLienMarkingStage, ext::setLienMarkingStage, changes);
setBoolean(command, "lienCertificateRequired", ext::getLienCertificateRequired, ext::setLienCertificateRequired,
changes);
setString(command, "lienCertificatePrefix", ext::getLienCertificatePrefix, ext::setLienCertificatePrefix,
changes);
setBoolean(command, "printCertificateOnDisbursement", ext::getPrintCertificateOnDisbursement,
ext::setPrintCertificateOnDisbursement, changes);
setString(command, "interestBasis", ext::getInterestBasis, ext::setInterestBasis, changes);
setDecimal(command, "spread", ext::getSpreadPct, ext::setSpreadPct, changes);
setString(command, "interestChargedOn", ext::getInterestChargedOn, ext::setInterestChargedOn, changes);
setString(command, "interestPostingFrequency", ext::getInterestPostingFrequency,
ext::setInterestPostingFrequency, changes);
setBoolean(command, "penalApplicable", ext::getPenalApplicable, ext::setPenalApplicable, changes);
setDecimal(command, "penalRate", ext::getPenalRatePct, ext::setPenalRatePct, changes);
setString(command, "penalAppliedOn", ext::getPenalAppliedOn, ext::setPenalAppliedOn, changes);
setInteger(command, "penalGraceDays", ext::getPenalGraceDays, ext::setPenalGraceDays, changes);
setString(command, "npaTrigger", ext::getNpaTrigger, ext::setNpaTrigger, changes);
setString(command, "interestAfterNpa", ext::getInterestAfterNpa, ext::setInterestAfterNpa, changes);
setLong(command, "undebitedInterestGlId", ext::getUndebitedInterestGlId, ext::setUndebitedInterestGlId, changes);
setBoolean(command, "renewalAllowed", ext::getRenewalAllowed, ext::setRenewalAllowed, changes);
setInteger(command, "renewalPeriodMonths", ext::getRenewalPeriodMonths, ext::setRenewalPeriodMonths, changes);
setInteger(command, "renewalGraceDays", ext::getRenewalGraceDays, ext::setRenewalGraceDays, changes);
setString(command, "preclosureAllowed", ext::getPreclosureAllowed, ext::setPreclosureAllowed, changes);
setInteger(command, "lockInMonths", ext::getLockInMonths, ext::setLockInMonths, changes);
setString(command, "interestOnPreclosure", ext::getInterestOnPreclosure, ext::setInterestOnPreclosure, changes);
setInteger(command, "revaluationFreqMonths", ext::getRevaluationFreqMonths, ext::setRevaluationFreqMonths,
changes);
setBoolean(command, "insuranceRequired", ext::getInsuranceRequired, ext::setInsuranceRequired, changes);
setInteger(command, "stockStatementFreqMonths", ext::getStockStatementFreqMonths,
ext::setStockStatementFreqMonths, changes);
setBoolean(command, "chequeBookEnabled", ext::getChequeBookEnabled, ext::setChequeBookEnabled, changes);
setString(command, "chequeType", ext::getChequeType, ext::setChequeType, changes);
setInteger(command, "defaultChequeBookSize", ext::getDefaultChequeBookSize, ext::setDefaultChequeBookSize,
changes);
setDecimal(command, "ppsThreshold", ext::getPpsThreshold, ext::setPpsThreshold, changes);
setDecimal(command, "ppsMandatoryAbove", ext::getPpsMandatoryAbove, ext::setPpsMandatoryAbove, changes);
setLong(command, "shareCapitalGl", ext::getShareCapitalGlId, ext::setShareCapitalGlId, changes);
// security & lien extras
setString(command, "eligibleDepositProducts", ext::getEligibleDepositProducts, ext::setEligibleDepositProducts,
changes);
setBoolean(command, "lienTenureNotExceedMaturity", ext::getLienTenureNotExceedMaturity,
ext::setLienTenureNotExceedMaturity, changes);
setBoolean(command, "lienAutoReleaseOnClosure", ext::getLienAutoReleaseOnClosure,
ext::setLienAutoReleaseOnClosure, changes);
setBoolean(command, "lienAllowMultipleDeposits", ext::getLienAllowMultipleDeposits,
ext::setLienAllowMultipleDeposits, changes);
setBoolean(command, "lienAdjustFromMaturity", ext::getLienAdjustFromMaturity, ext::setLienAdjustFromMaturity,
changes);
setBoolean(command, "lienAllowThirdParty", ext::getLienAllowThirdParty, ext::setLienAllowThirdParty, changes);
setBoolean(command, "lienAllowRenewalUnderLien", ext::getLienAllowRenewalUnderLien,
ext::setLienAllowRenewalUnderLien, changes);
// limit & tenure extras
setString(command, "repaymentType", ext::getRepaymentType, ext::setRepaymentType, changes);
setString(command, "appropriationOrder", ext::getAppropriationOrder, ext::setAppropriationOrder, changes);
setBoolean(command, "debitBalanceAllow", ext::getDebitBalanceAllow, ext::setDebitBalanceAllow, changes);
setBoolean(command, "allowDebitAfterDueDate", ext::getAllowDebitAfterDueDate, ext::setAllowDebitAfterDueDate,
changes);
setBoolean(command, "branchLevelAccountType", ext::getBranchLevelAccountType, ext::setBranchLevelAccountType,
changes);
// interest extras
setString(command, "interestCalcMethod", ext::getInterestCalcMethod, ext::setInterestCalcMethod, changes);
setBoolean(command, "interestOnCreditBalance", ext::getInterestOnCreditBalance, ext::setInterestOnCreditBalance,
changes);
setString(command, "interestPostingDay", ext::getInterestPostingDay, ext::setInterestPostingDay, changes);
// NPA upgrade rules
setBoolean(command, "npaUpgradeOnlyAllArrears", ext::getNpaUpgradeOnlyAllArrears,
ext::setNpaUpgradeOnlyAllArrears, changes);
setBoolean(command, "npaKeepStandardIfDepositCovers", ext::getNpaKeepStandardIfDepositCovers,
ext::setNpaKeepStandardIfDepositCovers, changes);
// cheque book full config
setString(command, "chequeBookSizes", ext::getChequeBookSizes, ext::setChequeBookSizes, changes);
setInteger(command, "chequeMaxBooksOutstanding", ext::getChequeMaxBooksOutstanding,
ext::setChequeMaxBooksOutstanding, changes);
setInteger(command, "chequeReissueWhenLeaves", ext::getChequeReissueWhenLeaves, ext::setChequeReissueWhenLeaves,
changes);
setBoolean(command, "chequeFirstBookFree", ext::getChequeFirstBookFree, ext::setChequeFirstBookFree, changes);
setInteger(command, "chequeFreeBooksPerYear", ext::getChequeFreeBooksPerYear, ext::setChequeFreeBooksPerYear,
changes);
setString(command, "chequeNamePrinted", ext::getChequeNamePrinted, ext::setChequeNamePrinted, changes);
setString(command, "chequePrintDescription", ext::getChequePrintDescription, ext::setChequePrintDescription,
changes);
setBoolean(command, "chequeStopPaymentAllowed", ext::getChequeStopPaymentAllowed,
ext::setChequeStopPaymentAllowed, changes);
setBoolean(command, "chequeMaintainStockRegister", ext::getChequeMaintainStockRegister,
ext::setChequeMaintainStockRegister, changes);
setBoolean(command, "chequeAllowWithdrawalSlip", ext::getChequeAllowWithdrawalSlip,
ext::setChequeAllowWithdrawalSlip, changes);
setBoolean(command, "chequeBlockNpa", ext::getChequeBlockNpa, ext::setChequeBlockNpa, changes);
setBoolean(command, "chequeBlockLimitExpired", ext::getChequeBlockLimitExpired, ext::setChequeBlockLimitExpired,
changes);
setBoolean(command, "chequeBlockOverDp", ext::getChequeBlockOverDp, ext::setChequeBlockOverDp, changes);
setBoolean(command, "chequeBlockFrozen", ext::getChequeBlockFrozen, ext::setChequeBlockFrozen, changes);
setBoolean(command, "chequeBlockLienReleased", ext::getChequeBlockLienReleased, ext::setChequeBlockLienReleased,
changes);
// Loans A/C (EMI) wizard fields
setString(command, "securityMandatory", ext::getSecurityMandatory, ext::setSecurityMandatory, changes);
setInteger(command, "guarantorsRequired", ext::getGuarantorsRequired, ext::setGuarantorsRequired, changes);
setString(command, "coApplicant", ext::getCoApplicant, ext::setCoApplicant, changes);
setString(command, "thirdPartySecurity", ext::getThirdPartySecurity, ext::setThirdPartySecurity, changes);
setBoolean(command, "legalOpinionRequired", ext::getLegalOpinionRequired, ext::setLegalOpinionRequired, changes);
setBoolean(command, "propertyInsuranceRequired", ext::getPropertyInsuranceRequired, ext::setPropertyInsuranceRequired, changes);
setBoolean(command, "registerCharge", ext::getRegisterCharge, ext::setRegisterCharge, changes);
setBoolean(command, "releaseSecurityOnClosure", ext::getReleaseSecurityOnClosure, ext::setReleaseSecurityOnClosure, changes);
setBoolean(command, "allowMultipleSecurities", ext::getAllowMultipleSecurities, ext::setAllowMultipleSecurities, changes);
setBoolean(command, "allowThirdPartyDeposit", ext::getAllowThirdPartyDeposit, ext::setAllowThirdPartyDeposit, changes);
setInteger(command, "minAge", ext::getMinAge, ext::setMinAge, changes);
setInteger(command, "maxAgeAtMaturity", ext::getMaxAgeAtMaturity, ext::setMaxAgeAtMaturity, changes);
setInteger(command, "minCibil", ext::getMinCibil, ext::setMinCibil, changes);
setDecimal(command, "minMonthlyIncome", ext::getMinMonthlyIncome, ext::setMinMonthlyIncome, changes);
setDecimal(command, "maxFoir", ext::getMaxFoir, ext::setMaxFoir, changes);
setInteger(command, "maxLoansPerCustomer", ext::getMaxLoansPerCustomer, ext::setMaxLoansPerCustomer, changes);
setString(command, "disbursementType", ext::getDisbursementType, ext::setDisbursementType, changes);
setString(command, "disbursementMode", ext::getDisbursementMode, ext::setDisbursementMode, changes);
setString(command, "undisbursedInterest", ext::getUndisbursedInterest, ext::setUndisbursedInterest, changes);
setBoolean(command, "preEmiInterest", ext::getPreEmiInterest, ext::setPreEmiInterest, changes);
setString(command, "emiDueDay", ext::getEmiDueDay, ext::setEmiDueDay, changes);
setString(command, "holidayRule", ext::getHolidayRule, ext::setHolidayRule, changes);
setInteger(command, "moratoriumMonths", ext::getMoratoriumMonths, ext::setMoratoriumMonths, changes);
setString(command, "moratoriumInterest", ext::getMoratoriumInterest, ext::setMoratoriumInterest, changes);
setBoolean(command, "variableInstallments", ext::getVariableInstallments, ext::setVariableInstallments, changes);
setString(command, "repaymentMode", ext::getRepaymentMode, ext::setRepaymentMode, changes);
setString(command, "linkedAccountType", ext::getLinkedAccountType, ext::setLinkedAccountType, changes);
setString(command, "pdcCollection", ext::getPdcCollection, ext::setPdcCollection, changes);
setBoolean(command, "mandateRequired", ext::getMandateRequired, ext::setMandateRequired, changes);
setString(command, "advanceEmi", ext::getAdvanceEmi, ext::setAdvanceEmi, changes);
setString(command, "rateType", ext::getRateType, ext::setRateType, changes);
setString(command, "registerParticular", ext::getRegisterParticular, ext::setRegisterParticular, changes);
setBoolean(command, "applyCibilAdjustment", ext::getApplyCibilAdjustment, ext::setApplyCibilAdjustment, changes);
setString(command, "rateResolvedAt", ext::getRateResolvedAt, ext::setRateResolvedAt, changes);
setString(command, "calcPeriod", ext::getCalcPeriod, ext::setCalcPeriod, changes);
setString(command, "interestStartsFrom", ext::getInterestStartsFrom, ext::setInterestStartsFrom, changes);
setString(command, "compounding", ext::getCompounding, ext::setCompounding, changes);
setString(command, "benchmark", ext::getBenchmark, ext::setBenchmark, changes);
setString(command, "resetFrequency", ext::getResetFrequency, ext::setResetFrequency, changes);
setString(command, "resetChange", ext::getResetChange, ext::setResetChange, changes);
setBoolean(command, "partPrepaymentAllowed", ext::getPartPrepaymentAllowed, ext::setPartPrepaymentAllowed, changes);
setDecimal(command, "minPartPayment", ext::getMinPartPayment, ext::setMinPartPayment, changes);
setString(command, "allowedOn", ext::getAllowedOn, ext::setAllowedOn, changes);
setString(command, "partPaymentsPerYear", ext::getPartPaymentsPerYear, ext::setPartPaymentsPerYear, changes);
setString(command, "partPrepaymentMode", ext::getPartPrepaymentMode, ext::setPartPrepaymentMode, changes);
setDecimal(command, "foreclosureFloatingIndividual", ext::getForeclosureFloatingIndividual, ext::setForeclosureFloatingIndividual, changes);
setDecimal(command, "foreclosureFixedIndividual", ext::getForeclosureFixedIndividual, ext::setForeclosureFixedIndividual, changes);
setDecimal(command, "foreclosureNonIndividual", ext::getForeclosureNonIndividual, ext::setForeclosureNonIndividual, changes);
setDecimal(command, "foreclosureTakeover", ext::getForeclosureTakeover, ext::setForeclosureTakeover, changes);
setInteger(command, "foreclosureQuoteValidDays", ext::getForeclosureQuoteValidDays, ext::setForeclosureQuoteValidDays, changes);
setBoolean(command, "autoCloseOnZeroBalance", ext::getAutoCloseOnZeroBalance, ext::setAutoCloseOnZeroBalance, changes);
setString(command, "nocLetter", ext::getNocLetter, ext::setNocLetter, changes);
setString(command, "restructuring", ext::getRestructuring, ext::setRestructuring, changes);
setBoolean(command, "releaseCollateralOnClosure", ext::getReleaseCollateralOnClosure, ext::setReleaseCollateralOnClosure, changes);
setBoolean(command, "topUpAllowed", ext::getTopUpAllowed, ext::setTopUpAllowed, changes);
setBoolean(command, "balanceTransferOutAllowed", ext::getBalanceTransferOutAllowed, ext::setBalanceTransferOutAllowed, changes);
}
// ---- typed setters: only write params that are present, and only report real changes ----------
private interface Getter<T> {
T get();
}
private interface Ser<T> {
void set(T value);
}
private <T> void putIfChanged(final String param, final T oldValue, final T newValue, final Ser<T> setter,
final Map<String, Object> changes) {
if (differs(oldValue, newValue)) {
setter.set(newValue);
changes.put(param, newValue);
}
}
private void setString(final JsonCommand command, final String param, final Getter<String> getter,
final Ser<String> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.stringValueOfParameterNamedAllowingNull(param), setter, changes);
}
}
private void setLong(final JsonCommand command, final String param, final Getter<Long> getter,
final Ser<Long> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.longValueOfParameterNamed(param), setter, changes);
}
}
private void setInteger(final JsonCommand command, final String param, final Getter<Integer> getter,
final Ser<Integer> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.integerValueOfParameterNamed(param), setter, changes);
}
}
private void setDecimal(final JsonCommand command, final String param, final Getter<BigDecimal> getter,
final Ser<BigDecimal> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), command.bigDecimalValueOfParameterNamed(param), setter, changes);
}
}
/**
* The form stores its yes/no dropdowns as the strings "YES"/"NO" and its checkboxes as real booleans;
* accept both shapes so a "NO" is not silently read as null.
*/
private void setBoolean(final JsonCommand command, final String param, final Getter<Boolean> getter,
final Ser<Boolean> setter, final Map<String, Object> changes) {
if (command.parameterExists(param)) {
putIfChanged(param, getter.get(), asBoolean(command.jsonElement(param)), setter, changes);
}
}
private static Boolean asBoolean(final JsonElement element) {
if (element == null || element.isJsonNull() || !element.isJsonPrimitive()) {
return null;
}
if (element.getAsJsonPrimitive().isBoolean()) {
return element.getAsBoolean();
}
final String raw = element.getAsString();
if (raw == null || raw.isBlank()) {
return null;
}
return "true".equalsIgnoreCase(raw) || "yes".equalsIgnoreCase(raw) || "1".equals(raw);
}
/** BigDecimal.equals compares scale, so 2.0 vs 2.00 would read as a change; compareTo does not. */
private static boolean differs(final Object oldValue, final Object newValue) {
if (oldValue instanceof BigDecimal a && newValue instanceof BigDecimal b) {
return a.compareTo(b) != 0;
}
return !Objects.equals(oldValue, newValue);
}
private static Map<String, Object> toMap(final LoanProductAdvanceExt ext) {
final Map<String, Object> map = new LinkedHashMap<>();
map.put("productTypeGroup", ext.getProductTypeGroup());
map.put("productTypeSubGroup", ext.getProductTypeSubGroup());
map.put("glAccount", ext.getGlAccountId());
map.put("loanProductCategory", ext.getLoanProductCategory());
map.put("prioritySector", ext.getPrioritySector());
map.put("cibilAccountType", ext.getCibilAccountType());
map.put("kfsEnabled", ext.getKfsEnabled());
map.put("aprEnabled", ext.getAprEnabled());
map.put("facilityType", ext.getFacilityType());
map.put("securityType", ext.getSecurityType());
map.put("chargeType", ext.getChargeType());
map.put("eligibleAmountBasis", ext.getEligibleAmountBasis());
map.put("margin", ext.getMarginPct());
map.put("ltv", ext.getLtvPct());
map.put("dpMethod", ext.getDpMethod());
map.put("lienRequired", ext.getLienRequired());
map.put("lienMarkingStage", ext.getLienMarkingStage());
map.put("lienCertificateRequired", ext.getLienCertificateRequired());
map.put("lienCertificatePrefix", ext.getLienCertificatePrefix());
map.put("printCertificateOnDisbursement", ext.getPrintCertificateOnDisbursement());
map.put("interestBasis", ext.getInterestBasis());
map.put("spread", ext.getSpreadPct());
map.put("interestChargedOn", ext.getInterestChargedOn());
map.put("interestPostingFrequency", ext.getInterestPostingFrequency());
map.put("penalApplicable", ext.getPenalApplicable());
map.put("penalRate", ext.getPenalRatePct());
map.put("penalAppliedOn", ext.getPenalAppliedOn());
map.put("penalGraceDays", ext.getPenalGraceDays());
map.put("npaTrigger", ext.getNpaTrigger());
map.put("interestAfterNpa", ext.getInterestAfterNpa());
map.put("undebitedInterestGlId", ext.getUndebitedInterestGlId());
map.put("renewalAllowed", ext.getRenewalAllowed());
map.put("renewalPeriodMonths", ext.getRenewalPeriodMonths());
map.put("renewalGraceDays", ext.getRenewalGraceDays());
map.put("preclosureAllowed", ext.getPreclosureAllowed());
map.put("lockInMonths", ext.getLockInMonths());
map.put("interestOnPreclosure", ext.getInterestOnPreclosure());
map.put("revaluationFreqMonths", ext.getRevaluationFreqMonths());
map.put("insuranceRequired", ext.getInsuranceRequired());
map.put("stockStatementFreqMonths", ext.getStockStatementFreqMonths());
map.put("chequeBookEnabled", ext.getChequeBookEnabled());
map.put("chequeType", ext.getChequeType());
map.put("defaultChequeBookSize", ext.getDefaultChequeBookSize());
map.put("ppsThreshold", ext.getPpsThreshold());
map.put("ppsMandatoryAbove", ext.getPpsMandatoryAbove());
map.put("shareCapitalGl", ext.getShareCapitalGlId());
map.put("eligibleDepositProducts", ext.getEligibleDepositProducts());
map.put("lienTenureNotExceedMaturity", ext.getLienTenureNotExceedMaturity());
map.put("lienAutoReleaseOnClosure", ext.getLienAutoReleaseOnClosure());
map.put("lienAllowMultipleDeposits", ext.getLienAllowMultipleDeposits());
map.put("lienAdjustFromMaturity", ext.getLienAdjustFromMaturity());
map.put("lienAllowThirdParty", ext.getLienAllowThirdParty());
map.put("lienAllowRenewalUnderLien", ext.getLienAllowRenewalUnderLien());
map.put("repaymentType", ext.getRepaymentType());
map.put("appropriationOrder", ext.getAppropriationOrder());
map.put("debitBalanceAllow", ext.getDebitBalanceAllow());
map.put("allowDebitAfterDueDate", ext.getAllowDebitAfterDueDate());
map.put("branchLevelAccountType", ext.getBranchLevelAccountType());
map.put("interestCalcMethod", ext.getInterestCalcMethod());
map.put("interestOnCreditBalance", ext.getInterestOnCreditBalance());
map.put("interestPostingDay", ext.getInterestPostingDay());
map.put("npaUpgradeOnlyAllArrears", ext.getNpaUpgradeOnlyAllArrears());
map.put("npaKeepStandardIfDepositCovers", ext.getNpaKeepStandardIfDepositCovers());
map.put("chequeBookSizes", ext.getChequeBookSizes());
map.put("chequeMaxBooksOutstanding", ext.getChequeMaxBooksOutstanding());
map.put("chequeReissueWhenLeaves", ext.getChequeReissueWhenLeaves());
map.put("chequeFirstBookFree", ext.getChequeFirstBookFree());
map.put("chequeFreeBooksPerYear", ext.getChequeFreeBooksPerYear());
map.put("chequeNamePrinted", ext.getChequeNamePrinted());
map.put("chequePrintDescription", ext.getChequePrintDescription());
map.put("chequeStopPaymentAllowed", ext.getChequeStopPaymentAllowed());
map.put("chequeMaintainStockRegister", ext.getChequeMaintainStockRegister());
map.put("chequeAllowWithdrawalSlip", ext.getChequeAllowWithdrawalSlip());
map.put("chequeBlockNpa", ext.getChequeBlockNpa());
map.put("chequeBlockLimitExpired", ext.getChequeBlockLimitExpired());
map.put("chequeBlockOverDp", ext.getChequeBlockOverDp());
map.put("chequeBlockFrozen", ext.getChequeBlockFrozen());
map.put("chequeBlockLienReleased", ext.getChequeBlockLienReleased());
// Loans A/C (EMI) wizard fields
map.put("securityMandatory", ext.getSecurityMandatory());
map.put("guarantorsRequired", ext.getGuarantorsRequired());
map.put("coApplicant", ext.getCoApplicant());
map.put("thirdPartySecurity", ext.getThirdPartySecurity());
map.put("legalOpinionRequired", ext.getLegalOpinionRequired());
map.put("propertyInsuranceRequired", ext.getPropertyInsuranceRequired());
map.put("registerCharge", ext.getRegisterCharge());
map.put("releaseSecurityOnClosure", ext.getReleaseSecurityOnClosure());
map.put("allowMultipleSecurities", ext.getAllowMultipleSecurities());
map.put("allowThirdPartyDeposit", ext.getAllowThirdPartyDeposit());
map.put("minAge", ext.getMinAge());
map.put("maxAgeAtMaturity", ext.getMaxAgeAtMaturity());
map.put("minCibil", ext.getMinCibil());
map.put("minMonthlyIncome", ext.getMinMonthlyIncome());
map.put("maxFoir", ext.getMaxFoir());
map.put("maxLoansPerCustomer", ext.getMaxLoansPerCustomer());
map.put("disbursementType", ext.getDisbursementType());
map.put("disbursementMode", ext.getDisbursementMode());
map.put("undisbursedInterest", ext.getUndisbursedInterest());
map.put("preEmiInterest", ext.getPreEmiInterest());
map.put("emiDueDay", ext.getEmiDueDay());
map.put("holidayRule", ext.getHolidayRule());
map.put("moratoriumMonths", ext.getMoratoriumMonths());
map.put("moratoriumInterest", ext.getMoratoriumInterest());
map.put("variableInstallments", ext.getVariableInstallments());
map.put("repaymentMode", ext.getRepaymentMode());
map.put("linkedAccountType", ext.getLinkedAccountType());
map.put("pdcCollection", ext.getPdcCollection());
map.put("mandateRequired", ext.getMandateRequired());
map.put("advanceEmi", ext.getAdvanceEmi());
map.put("rateType", ext.getRateType());
map.put("registerParticular", ext.getRegisterParticular());
map.put("applyCibilAdjustment", ext.getApplyCibilAdjustment());
map.put("rateResolvedAt", ext.getRateResolvedAt());
map.put("calcPeriod", ext.getCalcPeriod());
map.put("interestStartsFrom", ext.getInterestStartsFrom());
map.put("compounding", ext.getCompounding());
map.put("benchmark", ext.getBenchmark());
map.put("resetFrequency", ext.getResetFrequency());
map.put("resetChange", ext.getResetChange());
map.put("partPrepaymentAllowed", ext.getPartPrepaymentAllowed());
map.put("minPartPayment", ext.getMinPartPayment());
map.put("allowedOn", ext.getAllowedOn());
map.put("partPaymentsPerYear", ext.getPartPaymentsPerYear());
map.put("partPrepaymentMode", ext.getPartPrepaymentMode());
map.put("foreclosureFloatingIndividual", ext.getForeclosureFloatingIndividual());
map.put("foreclosureFixedIndividual", ext.getForeclosureFixedIndividual());
map.put("foreclosureNonIndividual", ext.getForeclosureNonIndividual());
map.put("foreclosureTakeover", ext.getForeclosureTakeover());
map.put("foreclosureQuoteValidDays", ext.getForeclosureQuoteValidDays());
map.put("autoCloseOnZeroBalance", ext.getAutoCloseOnZeroBalance());
map.put("nocLetter", ext.getNocLetter());
map.put("restructuring", ext.getRestructuring());
map.put("releaseCollateralOnClosure", ext.getReleaseCollateralOnClosure());
map.put("topUpAllowed", ext.getTopUpAllowed());
map.put("balanceTransferOutAllowed", ext.getBalanceTransferOutAllowed());
return map;
}
}
@@ -97,6 +97,8 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
private final CreditAllocationsJsonParser creditAllocationsJsonParser;
private final LoanProductAssembler loanProductAssembler;
private final LoanProductUpdateUtil loanProductUpdateUtil;
// "Advances A/C" satellite fields (security, lien, renewal, npa, cheque book) that m_product_loan has no home for.
private final LoanProductAdvanceExtService loanProductAdvanceExtService;
private final LoanProductPaymentAllocationRuleMerger loanProductPaymentAllocationRuleMerger = new LoanProductPaymentAllocationRuleMerger();
private final LoanProductCreditAllocationRuleMerger loanProductCreditAllocationRuleMerger = new LoanProductCreditAllocationRuleMerger();
@@ -141,6 +143,10 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(loanProduct);
// "Advances A/C" satellite fields, keyed on the product's generated id. A standard product carries
// none of these parameters and writes no row. Same transaction: a failure rolls the product back.
this.loanProductAdvanceExtService.saveOrUpdate(loanProduct, command);
// save accounting mappings
this.accountMappingWritePlatformService.createLoanProductToGLAccountMapping(loanProduct.getId(), command);
// check if the office specific products are enabled. If yes, then
@@ -303,6 +309,11 @@ public class LoanProductWritePlatformServiceJpaRepositoryImpl implements LoanPro
this.loanProductRepository.saveAndFlush(product);
}
// "Advances A/C" satellite fields. Outside the changes check above (those track only the standard
// product columns) so an edit that touched only an advance field is still saved, and its per-field
// diff is folded into the same audit changes map.
changes.putAll(this.loanProductAdvanceExtService.saveOrUpdate(product, command));
return new CommandProcessingResultBuilder() //
.withCommandId(command.commandId()) //
.withEntityId(loanProductId) //
@@ -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.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 {
/** All versions, newest-effective first — the Version History accordion feeds off this. */
List<LoanRateRegisterData> retrieveAll();
/** The current active version, assembled with its chart cells + CIBIL bands. */
Optional<LoanRateRegisterData> retrieveActive();
/** A specific version by id. */
Optional<LoanRateRegisterData> retrieveById(Long id);
/** Master list of particulars (loan types) that make up the chart rows, in chart order. */
List<LoanRateRegisterProductData> retrieveProducts();
}
@@ -0,0 +1,130 @@
/**
* 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.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;
/**
* 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;
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() {
final List<LoanRateRegisterEffective> versions = this.effectiveRepository.findAllByOrderByEffectiveFromDateDesc();
final List<LoanRateRegisterData> out = new ArrayList<>(versions.size());
for (final LoanRateRegisterEffective v : versions) {
out.add(assemble(v));
}
return out;
}
@Override
public Optional<LoanRateRegisterData> retrieveActive() {
return this.effectiveRepository.findFirstByIsActiveTrueOrderByEffectiveFromDateDesc().map(this::assemble);
}
@Override
public Optional<LoanRateRegisterData> retrieveById(final Long id) {
return this.effectiveRepository.findById(id).map(this::assemble);
}
/** Build the full response for one effective version — header + its chart cells + its CIBIL bands. */
private LoanRateRegisterData assemble(final LoanRateRegisterEffective v) {
final List<LoanRateRegisterSlabHistory> cells = this.slabRepository
.findByEffectiveIdOrderByProductSrNoAscSlabOrderAsc(v.getId());
final List<LoanRateRegisterCibilSlab> bands = this.cibilRepository
.findByEffectiveIdOrderByDisplayOrderAsc(v.getId());
final List<LoanRateRegisterSlabData> slabData = new ArrayList<>(cells.size());
for (final LoanRateRegisterSlabHistory c : cells) {
slabData.add(LoanRateRegisterSlabData.builder()
.id(c.getId())
.productId(c.getProductId())
.productSrNo(c.getProductSrNo())
.productName(c.getProductName())
.slabOrder(c.getSlabOrder())
.slabLabel(c.getSlabLabel())
.amountUpto(c.getAmountUpto())
.rateValue(c.getRateValue())
.rateFormula(c.getRateFormula())
.isFormula(c.getIsFormula())
.isNa(c.getIsNa())
.build());
}
final List<LoanRateRegisterCibilSlabData> cibilData = new ArrayList<>(bands.size());
for (final LoanRateRegisterCibilSlab b : bands) {
cibilData.add(LoanRateRegisterCibilSlabData.builder()
.id(b.getId())
.scoreFrom(b.getScoreFrom())
.scoreTo(b.getScoreTo())
.tier(b.getTier())
.slabType(b.getSlabType())
.adjustmentPct(b.getAdjustmentPct())
.displayOrder(b.getDisplayOrder())
.build());
}
return LoanRateRegisterData.builder()
.id(v.getId())
.name(v.getName())
.effectiveFromDate(v.getEffectiveFromDate())
.effectiveToDate(v.getEffectiveToDate())
.currencyCode(v.getCurrencyCode())
.isActive(v.getIsActive())
.fallbackNoCibilSlab(v.getFallbackNoCibilSlab())
.fallbackBelowMin(v.getFallbackBelowMin())
.note(v.getNote())
.createdById(v.getCreatedById())
.createdDate(v.getCreatedDate())
.slabs(slabData)
.cibilSlabs(cibilData)
.build();
}
}
@@ -0,0 +1,17 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
package org.apache.fineract.portfolio.loanproduct.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface LoanRateRegisterWritePlatformService {
/** Save a new version of the loan interest rate register (maker-checker gated upstream). */
CommandProcessingResult saveRateRegister(JsonCommand command);
}
@@ -0,0 +1,200 @@
/**
* 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.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;
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 LoanRateRegisterProductRepository productRepository;
private final GLAccountRepositoryWrapper glAccountRepositoryWrapper;
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);
// 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) {
prior.deactivate(today);
}
if (!active.isEmpty()) {
this.effectiveRepository.saveAll(active);
}
// 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.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()) {
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);
// 4) Insert all CIBIL bands for this version.
final List<LoanRateRegisterCibilSlab> bands = new ArrayList<>(req.getCibilSlabs().size());
for (final CibilSlabRequest c : req.getCibilSlabs()) {
bands.add(LoanRateRegisterCibilSlab.create(versionId, c.getScoreFrom(), c.getScoreTo(), c.getTier(),
c.getSlabType(), c.getAdjustmentPct(), c.getDisplayOrder()));
}
this.cibilRepository.saveAll(bands);
return new CommandProcessingResultBuilder()
.withCommandId(command.commandId())
.withEntityId(versionId)
.build();
}
/**
* 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;
}
return LocalDate.parse(iso, DateTimeFormatter.ISO_LOCAL_DATE);
}
}
@@ -43,6 +43,7 @@ import org.apache.fineract.portfolio.loanproduct.service.LoanDropdownReadPlatfor
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductReadPlatformServiceImpl;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductAdvanceExtService;
import org.apache.fineract.portfolio.loanproduct.service.LoanProductWritePlatformServiceJpaRepositoryImpl;
import org.apache.fineract.portfolio.rate.domain.RateRepositoryWrapper;
import org.apache.fineract.portfolio.rate.service.RateReadService;
@@ -82,11 +83,12 @@ public class LoanProductConfiguration {
DelinquencyBucketRepository delinquencyBucketRepository,
LoanRepaymentScheduleTransactionProcessorFactory loanRepaymentScheduleTransactionProcessorFactory,
AdvancedPaymentAllocationsJsonParser advancedPaymentJsonParser, CreditAllocationsJsonParser creditAllocationsJsonParser,
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil) {
LoanProductAssembler loanProductAssembler, LoanProductUpdateUtil loanProductUpdateUtil,
LoanProductAdvanceExtService loanProductAdvanceExtService) {
return new LoanProductWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, loanProductRepository, aprCalculator,
fundRepository, chargeRepository, rateRepository, accountMappingWritePlatformService, fineractEntityAccessUtil,
floatingRateRepository, loanRepositoryWrapper, businessEventNotifierService, delinquencyBucketRepository,
loanRepaymentScheduleTransactionProcessorFactory, advancedPaymentJsonParser, creditAllocationsJsonParser,
loanProductAssembler, loanProductUpdateUtil);
loanProductAssembler, loanProductUpdateUtil, loanProductAdvanceExtService);
}
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:UAT@DB_2025}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -170,6 +170,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=${FINERACT_LOAN_TRANSA
fineract.loan.transactionprocessor.heavensfamily.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_HEAVENSFAMILY_ENABLED:true}
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_INTEREST_PRINCIPAL_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_PRINCIPAL_INTEREST_PENALTIES_FEES_ENABLED:true}
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_FEES_PENALTIES_INTEREST_PRINCIPAL_ENABLED:true}
fineract.loan.transactionprocessor.rbi-india.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_RBI_INDIA_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_FEE_INTEREST_PRINCIPAL_IN_ADVANCE_PRINCIPAL_PENALTY_FEE_INTEREST_ENABLED:true}
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=${FINERACT_LOAN_TRANSACTIONPROCESSOR_DUE_PENALTY_INTEREST_PRINCIPAL_FEE_IN_ADVANCE_PENALTY_INTEREST_PRINCIPAL_FEE_ENABLED:true}
@@ -415,7 +416,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:UAT@DB_2025}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -615,4 +615,12 @@
<include file="parts/0539_map_profit_financial_activities.xml" relativeToChangelogFile="true"/>
<include file="parts/0544_dividend_round_off_financial_activity.xml" relativeToChangelogFile="true"/>
<include file="parts/0545_ensure_profit_financial_activities_mapped.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_create_loan_product_advance_ext.xml" relativeToChangelogFile="true"/>
<include file="parts/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"/>
<include file="parts/0596_enable_delinquency_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0597_add_loan_product_loans_ext_fields.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,112 @@
<?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">
<!-- Satellite table for "Advances A/C" loan-product settings that Fineract's m_product_loan has no
home for (security & margin, lien, deposit-linked interest, penal/NPA, renewal, monitoring,
cheque book). Keyed 1:1 by loan_product_id; standalone so the LoanProduct entity graph is
untouched. Only Advances products get a row. -->
<changeSet author="cbsnexora" id="0571_create_loan_product_advance_ext">
<createTable tableName="m_product_loan_advance_ext">
<column name="loan_product_id" type="BIGINT">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- classification -->
<column name="product_type_group" type="VARCHAR(50)"/>
<column name="product_type_sub_group" type="VARCHAR(50)"/>
<column name="gl_account_id" type="BIGINT"/>
<column name="loan_product_category" type="VARCHAR(50)"/>
<column name="priority_sector" type="VARCHAR(50)"/>
<column name="cibil_account_type" type="VARCHAR(50)"/>
<column name="kfs_enabled" type="BOOLEAN"/>
<column name="apr_enabled" type="BOOLEAN"/>
<!-- facility -->
<column name="facility_type" type="VARCHAR(30)"/>
<!-- security & margin -->
<column name="security_type" type="VARCHAR(50)"/>
<column name="charge_type" type="VARCHAR(30)"/>
<column name="eligible_amount_basis" type="VARCHAR(50)"/>
<column name="margin_pct" type="DECIMAL(9,4)"/>
<column name="ltv_pct" type="DECIMAL(9,4)"/>
<column name="dp_method" type="VARCHAR(50)"/>
<!-- lien -->
<column name="lien_required" type="BOOLEAN"/>
<column name="lien_marking_stage" type="VARCHAR(30)"/>
<column name="lien_certificate_required" type="BOOLEAN"/>
<column name="lien_certificate_prefix" type="VARCHAR(20)"/>
<column name="print_certificate_on_disbursement" type="BOOLEAN"/>
<!-- interest extras -->
<column name="interest_basis" type="VARCHAR(30)"/>
<column name="spread_pct" type="DECIMAL(9,4)"/>
<column name="interest_charged_on" type="VARCHAR(30)"/>
<column name="interest_posting_frequency" type="VARCHAR(30)"/>
<!-- penal & NPA extras -->
<column name="penal_applicable" type="BOOLEAN"/>
<column name="penal_rate_pct" type="DECIMAL(9,4)"/>
<column name="penal_applied_on" type="VARCHAR(30)"/>
<column name="penal_grace_days" type="INT"/>
<column name="npa_trigger" type="VARCHAR(30)"/>
<column name="interest_after_npa" type="VARCHAR(30)"/>
<column name="undebited_interest_gl_id" type="BIGINT"/>
<!-- renewal & closure -->
<column name="renewal_allowed" type="BOOLEAN"/>
<column name="renewal_period_months" type="INT"/>
<column name="renewal_grace_days" type="INT"/>
<column name="preclosure_allowed" type="VARCHAR(30)"/>
<column name="lock_in_months" type="INT"/>
<column name="interest_on_preclosure" type="VARCHAR(30)"/>
<!-- monitoring -->
<column name="revaluation_freq_months" type="INT"/>
<column name="insurance_required" type="BOOLEAN"/>
<column name="stock_statement_freq_months" type="INT"/>
<!-- cheque book -->
<column name="cheque_book_enabled" type="BOOLEAN"/>
<column name="cheque_type" type="VARCHAR(50)"/>
<column name="default_cheque_book_size" type="INT"/>
<column name="pps_threshold" type="DECIMAL(19,6)"/>
<column name="pps_mandatory_above" type="DECIMAL(19,6)"/>
<!-- share (co-op) -->
<column name="share_capital_gl_id" type="BIGINT"/>
</createTable>
</changeSet>
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_product_fk">
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="loan_product_id"
constraintName="fk_mplae_loan_product" referencedTableName="m_product_loan"
referencedColumnNames="id" onDelete="CASCADE"/>
</changeSet>
<changeSet author="cbsnexora" id="0571_loan_product_advance_ext_gl_fks">
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="gl_account_id"
constraintName="fk_mplae_gl_account" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="undebited_interest_gl_id"
constraintName="fk_mplae_undebited_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_product_loan_advance_ext" baseColumnNames="share_capital_gl_id"
constraintName="fk_mplae_share_capital_gl" referencedTableName="acc_gl_account"
referencedColumnNames="id"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,71 @@
<?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">
<!-- Remaining "Advances A/C" wizard fields so every field the officer fills is persisted:
lien rules, eligible deposit products, repayment/appropriation, account behaviour,
interest extras, NPA upgrade rules, and the full cheque-book configuration. -->
<changeSet author="cbsnexora" id="0572_add_loan_product_advance_ext_fields">
<addColumn tableName="m_product_loan_advance_ext">
<!-- security & lien extras -->
<column name="eligible_deposit_products" type="VARCHAR(500)"/>
<column name="lien_tenure_not_exceed_maturity" type="BOOLEAN"/>
<column name="lien_auto_release_on_closure" type="BOOLEAN"/>
<column name="lien_allow_multiple_deposits" type="BOOLEAN"/>
<column name="lien_adjust_from_maturity" type="BOOLEAN"/>
<column name="lien_allow_third_party" type="BOOLEAN"/>
<column name="lien_allow_renewal_under_lien" type="BOOLEAN"/>
<!-- limit & tenure extras -->
<column name="repayment_type" type="VARCHAR(40)"/>
<column name="appropriation_order" type="VARCHAR(60)"/>
<column name="debit_balance_allow" type="BOOLEAN"/>
<column name="allow_debit_after_due_date" type="BOOLEAN"/>
<column name="branch_level_account_type" type="BOOLEAN"/>
<!-- interest extras -->
<column name="interest_calc_method" type="VARCHAR(30)"/>
<column name="interest_on_credit_balance" type="BOOLEAN"/>
<column name="interest_posting_day" type="VARCHAR(30)"/>
<!-- NPA upgrade rules -->
<column name="npa_upgrade_only_all_arrears" type="BOOLEAN"/>
<column name="npa_keep_standard_if_deposit_covers" type="BOOLEAN"/>
<!-- cheque book full config -->
<column name="cheque_book_sizes" type="VARCHAR(50)"/>
<column name="cheque_max_books_outstanding" type="INT"/>
<column name="cheque_reissue_when_leaves" type="INT"/>
<column name="cheque_first_book_free" type="BOOLEAN"/>
<column name="cheque_free_books_per_year" type="INT"/>
<column name="cheque_name_printed" type="VARCHAR(50)"/>
<column name="cheque_print_description" type="VARCHAR(100)"/>
<column name="cheque_stop_payment_allowed" type="BOOLEAN"/>
<column name="cheque_maintain_stock_register" type="BOOLEAN"/>
<column name="cheque_allow_withdrawal_slip" type="BOOLEAN"/>
<column name="cheque_block_npa" type="BOOLEAN"/>
<column name="cheque_block_limit_expired" type="BOOLEAN"/>
<column name="cheque_block_over_dp" type="BOOLEAN"/>
<column name="cheque_block_frozen" type="BOOLEAN"/>
<column name="cheque_block_lien_released" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,193 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Central Loan Interest Rate Register — one approved rate chart (34 products × 10 amount
slabs) + CIBIL/credit-score adjustment bands. Mirrors the "CBS Base Interest Rate" (FD)
versioning model: each save creates a new effective row and deactivates the previous one.
Maker-checker gated through Fineract's standard command-source mechanism (see 0581).
-->
<!-- 1. Version header: one row per effective version. is_active flags the currently in-force one. -->
<changeSet id="0580a_create_loan_rate_register_effective" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_effective"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_effective">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<column name="effective_from_date" type="DATE">
<constraints nullable="false"/>
</column>
<column name="effective_to_date" type="DATE"/>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<column name="is_active" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<!-- Fallback behaviour when a loan's CIBIL score is unavailable: pointer into the CIBIL
slab list for this version (1-indexed, matches UI). -->
<column name="fallback_no_cibil_slab" type="INT" defaultValueNumeric="2">
<constraints nullable="false"/>
</column>
<!-- Fallback when CIBIL < min configured slab: 'use-min-slab' or 'block'. -->
<column name="fallback_below_min" type="VARCHAR(20)" defaultValue="block">
<constraints nullable="false"/>
</column>
<column name="note" type="VARCHAR(500)"/>
<column name="created_by_id" type="BIGINT"/>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 2. Rate chart cells: one row per {version × product × amount-slab}. Product name and slab
label are denormalised for query convenience + display. Cell value is either a plain rate,
a formula string ("1% + FD"), or N.A. (both value and formula null, is_na=true). -->
<changeSet id="0580b_create_loan_rate_register_slab_history" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_slab_history"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_slab_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="effective_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<!-- Product dimension -->
<column name="product_sr_no" type="INT">
<constraints nullable="false"/>
</column>
<column name="product_name" type="VARCHAR(200)">
<constraints nullable="false"/>
</column>
<!-- Amount-slab dimension -->
<column name="slab_order" type="INT">
<constraints nullable="false"/>
</column>
<column name="slab_label" type="VARCHAR(60)">
<constraints nullable="false"/>
</column>
<column name="amount_upto" type="DECIMAL(19,2)"/>
<!-- Cell value: at most ONE of rate_value / rate_formula is non-null; both null + is_na=true means slab not offered. -->
<column name="rate_value" type="DECIMAL(9,4)"/>
<column name="rate_formula" type="VARCHAR(50)"/>
<column name="is_formula" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="is_na" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 3. CIBIL/credit-score adjustment bands: 5 rows per version (typical), layered on top of the
chart rate at loan sanction. -->
<changeSet id="0580c_create_loan_rate_register_cibil_slab" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_loan_rate_register_cibil_slab"/></not>
</preConditions>
<createTable tableName="m_loan_rate_register_cibil_slab">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="effective_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="score_from" type="INT">
<constraints nullable="false"/>
</column>
<column name="score_to" type="INT">
<constraints nullable="false"/>
</column>
<column name="tier" type="VARCHAR(30)">
<constraints nullable="false"/>
</column>
<column name="slab_type" type="VARCHAR(20)" defaultValue="Flat">
<constraints nullable="false"/>
</column>
<column name="adjustment_pct" type="DECIMAL(5,2)" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
<column name="display_order" type="INT">
<constraints nullable="false"/>
</column>
<column name="created_date" type="DATETIME"/>
</createTable>
</changeSet>
<!-- 4. Foreign keys — effective_id in child tables cascades on delete so retiring a version
cleans up its slabs and CIBIL rows atomically. -->
<changeSet id="0580d_loan_rate_register_fks" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_slab_history"
baseColumnNames="effective_id"
constraintName="fk_mlrrsh_effective"
referencedTableName="m_loan_rate_register_effective"
referencedColumnNames="id"
onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_cibil_slab"
baseColumnNames="effective_id"
constraintName="fk_mlrrcs_effective"
referencedTableName="m_loan_rate_register_effective"
referencedColumnNames="id"
onDelete="CASCADE"/>
<addForeignKeyConstraint baseTableName="m_loan_rate_register_effective"
baseColumnNames="created_by_id"
constraintName="fk_mlrre_created_by"
referencedTableName="m_appuser"
referencedColumnNames="id"/>
</changeSet>
<!-- 5. Indexes for the hot read paths: (a) "give me the current active" (b) "give me a version's
rate for a product+slab" (c) "give me a version's CIBIL slabs ordered". -->
<changeSet id="0580e_loan_rate_register_indexes" author="cbsnexora">
<validCheckSum>ANY</validCheckSum>
<createIndex indexName="idx_mlrre_active" tableName="m_loan_rate_register_effective">
<column name="is_active"/>
<column name="effective_from_date"/>
</createIndex>
<createIndex indexName="idx_mlrrsh_lookup" tableName="m_loan_rate_register_slab_history">
<column name="effective_id"/>
<column name="product_sr_no"/>
<column name="slab_order"/>
</createIndex>
<createIndex indexName="idx_mlrrcs_order" tableName="m_loan_rate_register_cibil_slab">
<column name="effective_id"/>
<column name="display_order"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Enable maker-checker on the Loan Interest Rate Register. Mirrors 0470 for the FD side.
Two permissions: the maker's gated with can_maker_checker=true so submits land in
m_portfolio_command_source as PENDING; the checker approves from the standard Checker Inbox
via /makercheckers/{id}?command=approve, which fires 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>
-- 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_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 = 'LOANBASEINTERESTRATE'
AND action_name = 'CREATE';
UPDATE m_permission
SET action_name = 'CREATE_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>
<changeSet id="0581b_grant_loan_rate_register_permissions_to_roles" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Super user: both maker and checker
INSERT INTO m_role_permission (role_id, permission_id)
SELECT r.id, p.id
FROM m_role r
CROSS JOIN m_permission p
WHERE r.name = 'Super user'
AND p.code IN ('CREATE_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
);
-- 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_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
);
-- 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_LOANBASEINTERESTRATE'
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -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>
@@ -0,0 +1,111 @@
<?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">
<!--
Maker-checker on Delinquency Ranges and Buckets (the SMA / NPA classification master).
A bucket or range feeds the SMA / NPA class of every loan product linked to it, so create, update
and delete must not take effect on one person's click. The six commands (CREATE / UPDATE / DELETE on
DELINQUENCY_RANGE and DELINQUENCY_BUCKET) are gated with can_maker_checker = true, so each lands in
m_portfolio_command_source as PENDING and is applied only when a checker approves it from the
Checker Inbox. Each gets a matching *_CHECKER permission, which approving requires.
Roles: create / update / delete (maker) and the checker permissions go to Super user and the
bankadmin user's roles only. UPDATE and DELETE are taken away from every other role. CREATE is left
as it is for roles that already hold it. Widen or narrow this later from Admin > Roles.
-->
<changeSet id="0596a_delinquency_maker_checker_permissions" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code IN ('CREATE_DELINQUENCY_RANGE', 'UPDATE_DELINQUENCY_RANGE', 'DELETE_DELINQUENCY_RANGE',
'CREATE_DELINQUENCY_BUCKET', 'UPDATE_DELINQUENCY_BUCKET', 'DELETE_DELINQUENCY_BUCKET');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'organisation', v.code, v.entity_name, v.action_name, false
FROM (
SELECT 'CREATE_DELINQUENCY_RANGE_CHECKER' AS code, 'DELINQUENCY_RANGE' AS entity_name, 'CREATE_CHECKER' AS action_name
UNION ALL SELECT 'UPDATE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'UPDATE_CHECKER'
UNION ALL SELECT 'DELETE_DELINQUENCY_RANGE_CHECKER', 'DELINQUENCY_RANGE', 'DELETE_CHECKER'
UNION ALL SELECT 'CREATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'CREATE_CHECKER'
UNION ALL SELECT 'UPDATE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'UPDATE_CHECKER'
UNION ALL SELECT 'DELETE_DELINQUENCY_BUCKET_CHECKER', 'DELINQUENCY_BUCKET', 'DELETE_CHECKER'
) v
WHERE NOT EXISTS (SELECT 1 FROM m_permission p WHERE p.code = v.code);
</sql>
</changeSet>
<changeSet id="0596b_delinquency_permissions_to_admin_roles_only" author="cbsnexora" runOnChange="false">
<validCheckSum>ANY</validCheckSum>
<sql>
-- Super user: maker + checker for all six commands.
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.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_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): maker + checker for all six commands.
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT 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.entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND p.action_name IN ('CREATE', 'UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
-- Every other role loses UPDATE / DELETE on ranges and buckets (and holds no checker permission).
DELETE FROM m_role_permission
WHERE permission_id IN (
SELECT id FROM m_permission
WHERE entity_name IN ('DELINQUENCY_RANGE', 'DELINQUENCY_BUCKET')
AND action_name IN ('UPDATE', 'DELETE', 'CREATE_CHECKER', 'UPDATE_CHECKER', 'DELETE_CHECKER')
)
AND role_id NOT IN (
SELECT id FROM m_role WHERE name = 'Super user'
UNION
SELECT ur.role_id
FROM m_appuser u
JOIN m_appuser_role ur ON ur.appuser_id = u.id
WHERE u.username = 'bankadmin'
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,90 @@
<?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">
<!-- Loans A/C (EMI) wizard fields that Fineract's loan-product schema has no home for: collateral and
guarantor rules, borrower eligibility, disbursement, repayment mode, rate source / floating reset,
part-prepayment and foreclosure. Stored on the same per-product satellite as the Advances fields.
The columns are prefixed loan_ so they never clash with an Advances column. -->
<changeSet author="cbsnexora" id="0597_add_loan_product_loans_ext_fields">
<addColumn tableName="m_product_loan_advance_ext">
<column name="loan_security_mandatory" type="VARCHAR(100)"/>
<column name="loan_guarantors_required" type="INT"/>
<column name="loan_co_applicant" type="VARCHAR(100)"/>
<column name="loan_third_party_security" type="VARCHAR(100)"/>
<column name="loan_legal_opinion_required" type="BOOLEAN"/>
<column name="loan_property_insurance_required" type="BOOLEAN"/>
<column name="loan_register_charge" type="BOOLEAN"/>
<column name="loan_release_security_on_closure" type="BOOLEAN"/>
<column name="loan_allow_multiple_securities" type="BOOLEAN"/>
<column name="loan_allow_third_party_deposit" type="BOOLEAN"/>
<column name="loan_min_age" type="INT"/>
<column name="loan_max_age_at_maturity" type="INT"/>
<column name="loan_min_cibil" type="INT"/>
<column name="loan_min_monthly_income" type="DECIMAL(19,6)"/>
<column name="loan_max_foir" type="DECIMAL(19,6)"/>
<column name="loan_max_loans_per_customer" type="INT"/>
<column name="loan_disbursement_type" type="VARCHAR(100)"/>
<column name="loan_disbursement_mode" type="VARCHAR(100)"/>
<column name="loan_undisbursed_interest" type="VARCHAR(100)"/>
<column name="loan_pre_emi_interest" type="BOOLEAN"/>
<column name="loan_emi_due_day" type="VARCHAR(100)"/>
<column name="loan_holiday_rule" type="VARCHAR(100)"/>
<column name="loan_moratorium_months" type="INT"/>
<column name="loan_moratorium_interest" type="VARCHAR(100)"/>
<column name="loan_variable_installments" type="BOOLEAN"/>
<column name="loan_repayment_mode" type="VARCHAR(100)"/>
<column name="loan_linked_account_type" type="VARCHAR(100)"/>
<column name="loan_pdc_collection" type="VARCHAR(100)"/>
<column name="loan_mandate_required" type="BOOLEAN"/>
<column name="loan_advance_emi" type="VARCHAR(100)"/>
<column name="loan_rate_type" type="VARCHAR(100)"/>
<column name="loan_register_particular" type="VARCHAR(100)"/>
<column name="loan_apply_cibil_adjustment" type="BOOLEAN"/>
<column name="loan_rate_resolved_at" type="VARCHAR(100)"/>
<column name="loan_calc_period" type="VARCHAR(100)"/>
<column name="loan_interest_starts_from" type="VARCHAR(100)"/>
<column name="loan_compounding" type="VARCHAR(100)"/>
<column name="loan_benchmark" type="VARCHAR(100)"/>
<column name="loan_reset_frequency" type="VARCHAR(100)"/>
<column name="loan_reset_change" type="VARCHAR(100)"/>
<column name="loan_part_prepayment_allowed" type="BOOLEAN"/>
<column name="loan_min_part_payment" type="DECIMAL(19,6)"/>
<column name="loan_allowed_on" type="VARCHAR(100)"/>
<column name="loan_part_payments_per_year" type="VARCHAR(100)"/>
<column name="loan_part_prepayment_mode" type="VARCHAR(100)"/>
<column name="loan_foreclosure_floating_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_fixed_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_non_individual" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_takeover" type="DECIMAL(19,6)"/>
<column name="loan_foreclosure_quote_valid_days" type="INT"/>
<column name="loan_auto_close_on_zero_balance" type="BOOLEAN"/>
<column name="loan_noc_letter" type="VARCHAR(100)"/>
<column name="loan_restructuring" type="VARCHAR(100)"/>
<column name="loan_release_collateral_on_closure" type="BOOLEAN"/>
<column name="loan_top_up_allowed" type="BOOLEAN"/>
<column name="loan_balance_transfer_out_allowed" type="BOOLEAN"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -87,6 +87,7 @@ fineract.loan.transactionprocessor.mifos-standard.enabled=true
fineract.loan.transactionprocessor.heavensfamily.enabled=true
fineract.loan.transactionprocessor.interest-principal-penalties-fees.enabled=true
fineract.loan.transactionprocessor.principal-interest-penalties-fees.enabled=true
fineract.loan.transactionprocessor.fees-penalties-interest-principal.enabled=true
fineract.loan.transactionprocessor.rbi-india.enabled=true
fineract.loan.transactionprocessor.due-penalty-fee-interest-principal-in-advance-principal-penalty-fee-interest.enabled=true
fineract.loan.transactionprocessor.due-penalty-interest-principal-fee-in-advance-penalty-interest-principal-fee.enabled=true