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
110 changed files with 5079 additions and 4587 deletions
@@ -157,13 +157,15 @@ 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())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "RECURRINGDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
@@ -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";
@@ -3157,15 +3165,6 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder extendTenureRecurringDepositAccount(final Long accountId) {
this.actionName = "EXTENDTENURE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
this.entityId = accountId;
this.savingsId = accountId;
this.href = "/recurringdepositaccounts/" + accountId + "?command=extendTenure";
return this;
}
public CommandWrapperBuilder prematureCloseRecurringDepositAccount(final Long accountId) {
this.actionName = "PREMATURECLOSE";
this.entityName = "RECURRINGDEPOSITACCOUNT";
@@ -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;
@@ -75,8 +75,6 @@ public enum JobName {
AML_STR_WEEKLY_DIGEST("AML STR Weekly Digest"), //
CLIENT_BEHAVIOUR_WINDOW("Client Behaviour Window Computation"), //
SAVE_RBI_FORM_A_RETURN("Save RBI Form A Return"), //
EXECUTE_RD_AUTO_DEBIT("Execute RD Auto Debit"), //
APPLY_RD_MISSED_INSTALLMENT_PENALTY("Apply RD Missed Installment Penalty"), //
; //
private final String name;
@@ -432,10 +432,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("withdrawalFeeForTransfers");
recurringDepositRequestData.add("enableLockinPeriod");
recurringDepositRequestData.add("maturityInstructions");
@@ -495,10 +491,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("prematureWithdrawalOption");
recurringDepositRequestData.add("tenureFlexibility");
recurringDepositRequestData.add("lienMarked");
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add("missedInstallmentGraceDays");
recurringDepositRequestData.add("missedInstallmentPenaltyType");
recurringDepositRequestData.add("missedInstallmentPenaltyAmount");
recurringDepositRequestData.add("maturityInstructions");
recurringDepositRequestData.add("creditMaturityProceeds");
recurringDepositRequestData.add("penaltyGracePeriodMissedInstallments");
@@ -656,8 +648,6 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add(SavingsApiConstants.balanceSwapOptionParamName);
recurringDepositRequestData.add(SavingsApiConstants.msmeParamName);
recurringDepositRequestData.add(SavingsApiConstants.autoDebitFacilityParamName);
// Lien Marked: how much of this deposit may be given as loan, capped by its product's percentage.
recurringDepositRequestData.add("lienPercentage");
recurringDepositRequestData.add(isSeniorCitizenParamName);
recurringDepositRequestData.add(seniorCitizenInterestRateParamName);
return recurringDepositRequestData;
@@ -234,20 +234,6 @@ public final class SavingsAccountData implements Serializable {
return this.transferInterestToLinkedAccount;
}
/**
* True for a recurring deposit opened under a product whose Interest Calculation Type is "Simple": its interest
* never compounds. Transient because it is only read by the batch interest-posting engine.
*/
private transient boolean simpleInterest;
public void setSimpleInterest(final boolean simpleInterest) {
this.simpleInterest = simpleInterest;
}
public boolean isSimpleInterest() {
return this.simpleInterest;
}
public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate,
BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum,
EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum,
@@ -180,12 +180,6 @@ public final class SavingsHelper {
immediateWithdrawalOfInterest);
}
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) {
return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil,
immediateWithdrawalOfInterest, simpleInterest);
}
public Collection<Long> fetchPostInterestTransactionIds(Long accountId) {
return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId);
}
@@ -40,18 +40,6 @@ public class CompoundInterestHelper {
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled) {
return calculateInterestForAllPostingPeriods(currency, allPeriods, lockUntil, interestTransferEnabled, false);
}
/**
* @param simpleInterest
* - when true, earned interest is never added back to the balance it is earned on, so every period
* earns on the principal alone (a recurring deposit whose product's Interest Calculation Type is
* "Simple"). Compounding and posting periods still decide when interest is recognised and posted;
* they just stop changing how much is earned.
*/
public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List<PostingPeriod> allPeriods,
LocalDate lockUntil, Boolean interestTransferEnabled, final boolean simpleInterest) {
// sum up the 'rounded' values that are posted each posting period
Money interestEarned = Money.zero(currency);
@@ -59,8 +47,7 @@ public class CompoundInterestHelper {
// total interest earned in previous periods but not yet recognised
BigDecimal compoundedInterest = BigDecimal.ZERO;
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
for (final PostingPeriod postingPeriod : allPeriods) {
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
@@ -80,25 +67,4 @@ public class CompoundInterestHelper {
return interestEarned;
}
/**
* Interest values that refuse to compound: whatever a compounding period hands over to be compounded is dropped,
* so the balance interest is earned on never includes interest already earned.
*/
private static final class SimpleInterestValues extends CompoundInterestValues {
SimpleInterestValues() {
super(BigDecimal.ZERO, BigDecimal.ZERO);
}
@Override
public void setCompoundedInterest(final BigDecimal interestToBeCompounded) {
super.setCompoundedInterest(BigDecimal.ZERO);
}
@Override
public boolean isSimpleInterest() {
return true;
}
}
}
@@ -50,9 +50,4 @@ public class CompoundInterestValues {
this.uncompoundedInterest = BigDecimal.ZERO;
}
/** True when earned interest must never earn interest itself - not across periods, and not within one either. */
public boolean isSimpleInterest() {
return false;
}
}
@@ -94,18 +94,6 @@ public final class DailyCompoundingPeriod implements CompoundingPeriod {
return interestEarned;
}
/** Interest on each day's balance alone, none of it added to the balance - for simple interest. */
public BigDecimal calculateSimpleInterest(final BigDecimal interestRateAsFraction, final long daysInYear,
final BigDecimal minBalanceForInterestCalculation, final BigDecimal overdraftInterestRateAsFraction,
final BigDecimal minOverdraftForInterestCalculation) {
BigDecimal interestEarned = BigDecimal.ZERO;
for (final EndOfDayBalance balance : this.endOfDayBalances) {
interestEarned = interestEarned.add(balance.calculateInterestOnBalance(BigDecimal.ZERO, interestRateAsFraction, daysInYear,
minBalanceForInterestCalculation, overdraftInterestRateAsFraction, minOverdraftForInterestCalculation));
}
return interestEarned;
}
@Override
public LocalDateInterval getPeriodInterval() {
return this.periodInterval;
@@ -301,17 +301,10 @@ public final class PostingPeriod {
// to be applied to the balanced for interest calculation
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
// A daily compounding period compounds day by day inside itself, so simple interest has to bypass it - zeroing
// what is carried between periods alone still left a month's interest earning interest within the month.
final BigDecimal interestUnrounded = compoundInterestValues.isSimpleInterest()
&& compoundingPeriod instanceof DailyCompoundingPeriod dailyPeriod
? dailyPeriod.calculateSimpleInterest(this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount())
: compoundingPeriod.calculateInterest(this.interestCompoundingType, this.interestCalculationType,
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction, this.daysInYear,
this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
this.minOverdraftForInterestCalculation.getAmount());
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
@@ -18,29 +18,20 @@
*/
package org.apache.fineract.infrastructure.documentmanagement.service;
import java.io.FileNotFoundException;
import java.nio.file.NoSuchFileException;
import java.util.List;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.contentstore.service.ContentStoreService;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformDomainRuleException;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentContent;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.domain.DocumentRepository;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentInvalidRequestException;
import org.apache.fineract.infrastructure.documentmanagement.exception.DocumentNotFoundException;
import org.apache.fineract.infrastructure.documentmanagement.mapping.DocumentMapper;
import org.springframework.stereotype.Service;
@Slf4j
@Service
@RequiredArgsConstructor
public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformService {
/** Bound on the cause walk below, so a self-referencing cause chain cannot spin. */
private static final int MAX_CAUSE_DEPTH = 10;
private final ContentStoreService storeService;
private final DocumentRepository documentRepository;
private final DocumentMapper documentMapper;
@@ -61,39 +52,9 @@ public class DocumentReadPlatformServiceImpl implements DocumentReadPlatformServ
public DocumentContent retrieveDocumentContent(final String entityType, final Long entityId, final Long documentId) {
final var doc = documentRepository.findByIdAndParentEntityTypeAndParentEntityId(documentId, entityType, entityId)
.orElseThrow(() -> new DocumentNotFoundException(entityType, entityId, documentId));
try {
final var is = storeService.download(doc.getLocation());
return documentMapper.map(doc, is);
} catch (final AbstractPlatformDomainRuleException e) {
throw e;
} catch (final Exception e) {
if (isMissingFromContentStore(e)) {
// The m_document row survives but the file it points at does not — the usual cause is
// a database copied between environments without its file store (see the identical
// guard in ImageReadPlatformServiceImpl). For the caller that is simply "this document
// has no content", not a server fault, so report it the same way as a missing document
// instead of letting a raw ContentStoreException surface as a 500.
log.warn("Document {} for {} {} is recorded but missing from the content store; treating it as not found", documentId,
entityType, entityId);
throw new DocumentNotFoundException(entityType, entityId, documentId);
}
throw new DocumentInvalidRequestException(e);
}
}
final var is = storeService.download(doc.getLocation());
/**
* Whether a failure bottoms out in "the stored file is not there", however it was wrapped on the
* way up (the file store rethrows as ContentStoreException).
*/
private boolean isMissingFromContentStore(final Throwable failure) {
Throwable cause = failure;
for (int depth = 0; cause != null && depth < MAX_CAUSE_DEPTH; depth++) {
if (cause instanceof NoSuchFileException || cause instanceof FileNotFoundException) {
return true;
}
cause = cause.getCause();
}
return false;
return documentMapper.map(doc, is);
}
@Override
@@ -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);
}
@@ -650,7 +650,7 @@ public class AccountingProcessorHelper {
*/
public void createInterestPostingJournalEntriesForSavings(final Office office, final String currencyCode,
final Long savingsProductId, final Long paymentTypeId, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
@@ -665,14 +665,10 @@ public class AccountingProcessorHelper {
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring Deposit Interest Payable",
"Interest Payable", "Interest Payable Account")))
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
// The RD product screen saves its paid account as "Recurring Deposit Interest Paid"; without that name here an
// RD's interest skipped the paid leg even when the product had one mapped.
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
"Interest Paid Account"))
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
.orElse(payableAccount);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
@@ -710,19 +706,6 @@ public class AccountingProcessorHelper {
// debit balance would come back negative and every posting would raise a shortfall larger
// than the interest itself.
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
if (!payableCarriesAccrual) {
// Cash basis: nothing ever accrues into the payable, so whatever already sits on it - an opening balance
// brought over at migration, or another product's interest sharing the GL - is not this interest. Reading
// it as "already accrued" skipped the expense and drew that balance down instead, leaving the cost off the
// P&L. The whole amount is charged and raised, and a reversal or clawback takes it back the same way.
if (payableIsLiability) {
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId,
transactionDate, amount, isReversal);
return;
}
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
@@ -735,14 +718,6 @@ public class AccountingProcessorHelper {
shortfall, false);
}
postPayableToMember(office, currencyCode, payableAccount, paidAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
}
/** Stages 2 and 3: out of the payable, through the paid account when the product names one, into the member. */
private void postPayableToMember(final Office office, final String currencyCode, final GLAccount payableAccount,
final GLAccount paidAccount, final GLAccount controlAccount, final Long savingsId, final String transactionId,
final LocalDate transactionDate, final BigDecimal amount, final boolean isReversal) {
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
@@ -194,7 +194,7 @@ public class AccrualBasedAccountingProcessorForSavings implements AccountingProc
// payable-to-control pair that used to be here, which skipped interest-paid
// entirely and left the expense off the P&L.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -186,7 +186,7 @@ public class CashBasedAccountingProcessorForSavings implements AccountingProcess
// to the plain Dr Interest On Savings / Cr Savings Control pair for a product
// that names neither.
this.helper.createInterestPostingJournalEntriesForSavings(office, currencyCode, savingsProductId, paymentTypeId,
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal);
}
}
@@ -294,14 +294,8 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc
SavingProductAccountingParams.TRANSFERS_SUSPENSE.getValue(), savingProductId,
CashAccountsForSavings.TRANSFERS_SUSPENSE.getValue());
// FD and RD products switch dormancy on with "dormancyAlert" (DepositProductAssembler derives the product's
// tracking flag from it) and never send isDormancyTrackingActive, so reading only that dropped the escheat
// account the screen had asked for - the product then tracked dormancy with no escheat account mapped.
final Boolean isDormancyTrackingActive = this.fromApiJsonHelper.extractBooleanNamed(isDormancyTrackingActiveParamName, element);
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
: null;
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
@@ -27,7 +27,6 @@ import static org.apache.fineract.portfolio.account.api.AccountTransfersApiConst
import com.google.common.collect.Lists;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -45,7 +44,6 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
@@ -76,7 +74,6 @@ import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.domain.GSIMRepositoy;
import org.apache.fineract.portfolio.savings.domain.GroupSavingsIndividualMonitoring;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction;
@@ -152,7 +149,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (!fromSavingsAccount.getCurrency().getCode().equals(toSavingsAccount.getCurrency().getCode())) {
throw new DifferentCurrenciesException(fromSavingsAccount.getCurrency().getCode(),
@@ -208,7 +204,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -414,7 +409,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), transactionDate, accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleSavingsToSavingsTransfer(accountTransferDTO, fromSavingsAccount,
toSavingsAccount, withdrawal, deposit);
@@ -460,7 +454,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount,
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(), accountTransferDTO.getTransactionAmount(),
accountTransferDTO.getPaymentDetail(), isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(accountTransferDTO, fromLoanAccount,
toSavingsAccount, deposit, loanTransaction);
this.accountTransferDetailRepository.saveAndFlush(accountTransferDetails);
@@ -495,7 +488,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
accountTransferDTO.getFmt(), accountTransferDTO.getTransactionDate(),
accountTransferDTO.getTransactionAmount(), accountTransferDTO.getPaymentDetail(),
isAccountTransfer, isRegularTransaction, backdatedTxnsAllowedTill, true);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
if (accountTransferDetails == null) {
accountTransferDetails = AccountTransferDetails.currentToSavingsTransfer(
@@ -657,26 +649,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
return accountTransferDetails;
}
/**
* Money transferred into a recurring deposit is an installment payment, exactly like a counter deposit through
* DepositAccountDomainServiceJpa#handleRDDeposit. The generic deposit above only credits the balance, so without
* this the deposit's schedule kept showing the installment as unpaid and overdue, and its projected maturity
* amount never moved - for manual transfers, standing instructions and RD Auto Debit alike. Any other destination
* is left untouched.
*/
private void applyToRecurringDepositSchedule(final SavingsAccount toSavingsAccount, final SavingsAccountTransaction deposit) {
if (!(toSavingsAccount instanceof RecurringDepositAccount recurringDeposit)) {
return;
}
final boolean isPreMatureClosure = false;
recurringDeposit.updateDepositAmount(deposit.getAmount());
recurringDeposit.handleScheduleInstallments(deposit);
recurringDeposit.updateMaturityDateAndAmount(MathContext.DECIMAL64, isPreMatureClosure,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
recurringDeposit.updateOverduePayments(DateUtils.getBusinessLocalDate());
}
private boolean isLoanToSavingsAccountTransfer(final PortfolioAccountType fromAccountType, final PortfolioAccountType toAccountType) {
return fromAccountType.isLoanAccount() && toAccountType.isSavingsAccount();
}
@@ -885,7 +857,6 @@ public class AccountTransfersWritePlatformServiceImpl implements AccountTransfer
final SavingsAccountTransaction deposit = this.savingsAccountDomainService.handleDeposit(toSavingsAccount, fmt, transactionDate,
transactionAmount, paymentDetail, true, true, backdatedTxnsAllowedTill);
applyToRecurringDepositSchedule(toSavingsAccount, deposit);
final AccountTransferDetails accountTransferDetails = this.accountTransferAssembler.assembleLoanToSavingsTransfer(command,
fromLoanAccount, toSavingsAccount, deposit, loanRefundTransaction);
@@ -789,13 +789,11 @@ public class BranchDayRegisterApiResource {
final Long registerId = ((Number) found.get(0).get("id")).longValue();
saveDenominationDetail(registerId, "CLOSING", denominations);
snapshotVaultDenominations(officeId, registerId, businessDate);
final int depositsClosed = closeMaturedZeroBalanceRecurringDeposits(officeId, businessDate, currentUser);
final Map<String, Object> res = new LinkedHashMap<>();
res.put("officeId", officeId);
res.put("businessDate", businessDate.toString());
res.put("status", "CLOSED");
res.put("zeroBalanceDepositsClosed", depositsClosed);
res.put("closingCash", closingCash);
res.put("bookCash", bookCash);
res.put("cashDifference", difference);
@@ -803,38 +801,6 @@ public class BranchDayRegisterApiResource {
return gson.toJson(res);
}
/**
* "Account Closed with Zero balance in Day Closing" on a recurring deposit product: at the branch's day close, each
* of its recurring deposits that has MATURED with nothing left in it - no balance, and no loan holding a lien on it
* - is marked Closed on that business date, so it no longer waits in the matured list.
*
* Only matured deposits: a running deposit sits at zero between opening and its first installment, and must never
* be closed for that. Nothing is paid out (there is nothing to pay), so no transaction or journal entry is made.
*
* @return how many deposits were closed
*/
private int closeMaturedZeroBalanceRecurringDeposits(final Long officeId, final LocalDate businessDate, final AppUser currentUser) {
final int closed = jdbcTemplate.update("""
UPDATE m_savings_account sa
SET status_enum = 600, closedon_date = ?, closedon_userid = ?
FROM m_savings_product sp, m_client c
WHERE sp.id = sa.product_id AND c.id = sa.client_id AND c.office_id = ?
AND sa.deposit_type_enum = 300 AND sa.status_enum = 800
AND COALESCE(sp.account_closed_zero_balance_in_day_closing, false) = true
AND COALESCE(sa.account_balance_derived, 0) = 0
AND NOT EXISTS (SELECT 1 FROM m_portfolio_account_associations paa
JOIN m_guarantor_funding_details gfd ON gfd.account_associations_id = paa.id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = sa.id AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0)
""", Date.valueOf(businessDate), currentUser.getId(), officeId);
if (closed > 0) {
log.info("Day close {}: office {} closed {} matured recurring deposit(s) with zero balance", businessDate, officeId, closed);
}
return closed;
}
// ── GET /v1/branchday/denominations ── the note/coin breakdown behind a day's lump sum ────
/**
@@ -176,7 +176,6 @@ public class ClerkDisbursementRequestApiResource {
// A GL leg carries no savingsAccountId and the guard ignores it.
// A CURRENT leg carries the current account's id in savingsAccountId, so it is checked against
// m_current_account -- checking it as a savings id would test some unrelated savings account.
rejectRecurringDepositLeg(entry, entryAccountType);
if (entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
final KycRestrictionGuard.Operation operation = "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER
: KycRestrictionGuard.Operation.CREDIT;
@@ -487,8 +486,6 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(400, "Account ID missing for entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
}
// Also refuses requests raised before RD legs were blocked at submit.
rejectRecurringDepositLeg(entry, entryType);
}
}
@@ -935,7 +932,6 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
drAccountIds.add(e.get("savingsAccountId").getAsLong());
}
for (final JsonObject e : crEntries) {
@@ -948,7 +944,6 @@ public class ClerkDisbursementRequestApiResource {
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
rejectRecurringDepositLeg(e, acTyp);
final Long crId = e.get("savingsAccountId").getAsLong();
if (drAccountIds.contains(crId)) throw apiError(400,
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
@@ -1198,31 +1193,6 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null);
}
/** m_savings_account.deposit_type_enum of a recurring deposit (Fineract's DepositAccountType.RECURRING_DEPOSIT). */
private static final int DEPOSIT_TYPE_RECURRING = 300;
/**
* A recurring deposit cannot be a leg of any clerk request. Every request type here posts straight into the
* account with a direct insert, which never reaches the RD's installment schedule: the installments stay unpaid,
* Auto Debit collects them a second time and the maturity amount is computed on the wrong schedule. RD
* installments come in through the cashier's Receipt (the RD deposit command) or the RD's own Auto Debit.
* Looked up by id, not trusted from the entry's label; a CURRENT leg's id belongs to m_current_account.
*/
private void rejectRecurringDepositLeg(final JsonObject entry, final String accountType) {
if ("GL".equals(accountType) || "CURRENT".equals(accountType) || !entry.has("savingsAccountId")
|| entry.get("savingsAccountId").isJsonNull()) {
return;
}
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_no FROM m_savings_account WHERE id = ? AND deposit_type_enum = ?",
entry.get("savingsAccountId").getAsLong(), DEPOSIT_TYPE_RECURRING);
if (!rows.isEmpty()) {
throw apiError(422, "Recurring deposit " + rows.get(0).get("account_no")
+ " cannot be used in a clerk Disbursement, Collection, Fund Transfer or Bank Transfer."
+ " Pay RD installments through a cashier Receipt or Auto Debit.");
}
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
@@ -390,101 +390,6 @@ public class ClerkShareWithdrawalRequestApiResource {
public String validate(@PathParam("id") final Long id) {
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(record);
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* Lets the clerk see the same checklist the approver will see, before the request is even
* submitted — so a request that is going to fail a check (a missing GL mapping, an unverified
* KYC, an open AML alert) is caught while someone can still fix it, not after a manager has
* already spent time reviewing it. Takes the same fields {@link #submit(String)} does and
* applies the identical Sundry-downgrade rule, so what this previews is exactly what would be
* stored and exactly what {@link #validate(Long)} would later report on the saved request —
* never a second, independently-drifting copy of the rules.
*
* <p>
* The one check {@link #validate(Long)} runs that this cannot — "Maker and Checker are
* different users" — has no real answer yet: the clerk previewing this *is* the maker, and no
* checker has been chosen. It is reported here as a fixed reminder rather than silently
* dropped, so the clerk sees why the two lists differ by exactly that one row, and knows the
* rule is still enforced later, at {@code approve()}.
*/
@POST
@Path("/preview-validate")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public String previewValidate(final String body) {
context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body);
final Long shareAccountId = fromJsonHelper.extractLongNamed("shareAccountId", root);
if (shareAccountId == null) {
throw apiError(400, "Share account id is required");
}
final Long requestedShares = fromJsonHelper.extractLongNamed("requestedShares", root);
final String resolutionNumber = fromJsonHelper.extractStringNamed("resolutionNumber", root);
final String meetingDate = fromJsonHelper.extractStringNamed("meetingDate", root);
final Long settlementPaymentTypeId = fromJsonHelper.extractLongNamed("settlementPaymentTypeId", root);
final String settlementModeRaw = fromJsonHelper.extractStringNamed("settlementMode", root);
String settlementMode = settlementModeRaw == null || settlementModeRaw.isBlank() ? "SAVINGS"
: settlementModeRaw.trim().toUpperCase();
Long settlementGlAccountId = fromJsonHelper.extractLongNamed("settlementGlAccountId", root);
// Mirrors submit()'s own downgrade: naming the product's own Share Sundry GL as the
// destination means "stop here", not a real GL settlement — see submit() for the rationale.
if ("GL".equals(settlementMode) && settlementGlAccountId != null) {
final Long productId = jdbcTemplate.queryForList("SELECT product_id FROM m_share_account WHERE id = ?", shareAccountId)
.stream().findFirst().map(r -> ((Number) r.get("product_id")).longValue()).orElse(null);
if (productId != null) {
final Long glSundry = resolveGl(productId, FA_SHARES_SUSPENSE);
if (glSundry != null && glSundry.equals(settlementGlAccountId)) {
settlementMode = "NONE";
settlementGlAccountId = null;
}
}
}
final Map<String, Object> draftRecord = new java.util.HashMap<>();
draftRecord.put("resolution_number", resolutionNumber);
draftRecord.put("meeting_date", meetingDate);
draftRecord.put("share_account_id", shareAccountId);
draftRecord.put("requested_shares", requestedShares != null ? requestedShares : 0L);
draftRecord.put("settlement_mode", settlementMode);
draftRecord.put("settlement_gl_account_id", settlementGlAccountId);
draftRecord.put("settlement_payment_type_id", settlementPaymentTypeId);
final List<Map<String, Object>> checks = buildShareWithdrawalChecks(draftRecord);
checks.add(checkResult("Maker and Checker are different users", true,
"Verified automatically when a different manager approves"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
/**
* The full checklist a checker sees before approving a share withdrawal — share balance and
* status, loan/tenure/guarantor exposure, settlement destination and payment type, and client
* KYC/risk/AML standing. Shared verbatim between {@link #validate(Long)} (an already-submitted
* request) and {@link #previewValidate(String)} (a draft that does not exist yet), so the two
* can never silently diverge. Reads {@code record} by the same string keys both callers
* populate: resolution_number, meeting_date, share_account_id, requested_shares,
* settlement_mode, settlement_gl_account_id, settlement_payment_type_id.
*/
private List<Map<String, Object>> buildShareWithdrawalChecks(final Map<?, ?> record) {
final List<Map<String, Object>> checks = new ArrayList<>();
final String resolutionNumber = (String) record.get("resolution_number");
@@ -687,7 +592,16 @@ public class ClerkShareWithdrawalRequestApiResource {
openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None"));
}
return checks;
final Long makerId = ((Number) record.get("clerk_id")).longValue();
final boolean makerCheckerOk = !makerId.equals(currentUser.getId());
checks.add(checkResult("Maker and Checker are different users", makerCheckerOk,
makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it"));
final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass")));
final JsonObject res = new JsonObject();
res.addProperty("allPassed", allPassed);
res.add("checks", gson.toJsonTree(checks));
return gson.toJson(res);
}
private Map<String, Object> checkResult(final String label, final boolean pass, final String detail) {
@@ -55,7 +55,6 @@ import org.apache.fineract.portfolio.loanaccount.guarantor.exception.InvalidGuar
import org.apache.fineract.portfolio.loanaccount.guarantor.serialization.GuarantorCommandFromApiJsonDeserializer;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -80,7 +79,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
private final SavingsAccountAssembler savingsAccountAssembler;
private final AccountAssociationsRepository accountAssociationsRepository;
private final GuarantorDomainService guarantorDomainService;
private final RecurringDepositLienService recurringDepositLienService;
@Autowired
public GuarantorWritePlatformServiceJpaRepositoryIImpl(final LoanRepositoryWrapper loanRepositoryWrapper,
@@ -88,9 +86,7 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
final StaffRepositoryWrapper staffRepositoryWrapper, final GroupRepositoryWrapper groupRepositoryWrapper,
final GuarantorCommandFromApiJsonDeserializer fromApiJsonDeserializer,
final CodeValueRepositoryWrapper codeValueRepositoryWrapper, final SavingsAccountAssembler savingsAccountAssembler,
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService,
final RecurringDepositLienService recurringDepositLienService) {
this.recurringDepositLienService = recurringDepositLienService;
final AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService) {
this.loanRepositoryWrapper = loanRepositoryWrapper;
this.clientRepositoryWrapper = clientRepositoryWrapper;
this.groupRepositoryWrapper = groupRepositoryWrapper;
@@ -124,9 +120,6 @@ public class GuarantorWritePlatformServiceJpaRepositoryIImpl implements Guaranto
final SavingsAccount savingsAccount = this.savingsAccountAssembler.assembleFrom(guarantorCommand.getSavingsId(),
backdatedTxnsAllowedTill);
validateGuarantorSavingsAccountActivationDateWithLoanSubmittedOnDate(loan, savingsAccount);
// A recurring deposit pledged here is a lien on it: its product must allow that, and it can
// never secure more than the balance it has not already pledged.
this.recurringDepositLienService.validatePledge(savingsAccount, guarantorCommand.getAmount(), loan.getId());
accountAssociations = AccountAssociations.associateSavingsAccount(loan, savingsAccount,
AccountAssociationType.GUARANTOR_ACCOUNT_ASSOCIATION.getValue(), backdatedTxnsAllowedTill);
@@ -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);
}
}
@@ -71,11 +71,9 @@ public class RbiReserveConfigApiResource {
static {
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
// BALANCE_WITH_OTHER_BANKS is deliberately not offered. It belongs to the older working
// report, and the statutory Form 1 this bank files has no line for it: balances with other
// banks are reported there under III(a), III(b)(i) or VI, depending on which bank holds
// them. Left on the screen it invited a mapping that would raise item X without appearing
// anywhere on the form to say where the figure came from.
ROLE_DESCRIPTIONS.put("BALANCE_WITH_OTHER_BANKS",
"Current account balances the bank holds with other banks. Counts toward CRR maintained. "
+ "Pin every such account - a bank typically banks with several.");
ROLE_DESCRIPTIONS.put("DEMAND_TIME_LIABILITIES",
"Deposit liabilities forming the DTL base that CRR and SLR are charged on. "
+ "Pin the deposit accounts and any interest accrued on them.");
@@ -1286,22 +1286,16 @@ public class RbiReserveReturnApiResource {
final Rate slr) {
final List<Map<String, Object>> lines = new ArrayList<>();
// The labels below are the printed form's own wording, kept word for word. A shortened line
// reads more easily on screen but this form is filed and signed, and a reader checking it
// against the paper return has to find each line by its text - a paraphrase makes them stop
// and work out whether it is the same line.
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM :"));
lines.add(formOneHeading("(a) Demand Liabilities:"));
final List<BigDecimal> iai = formOneLine(lines,
"(i) Total of credit balances in current accounts maintained with the co-operative bank by State Bank of India,"
+ " subsidiary banks and corresponding new banks.",
lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM"));
lines.add(formOneHeading("(a) Demand Liabilities"));
final List<BigDecimal> iai = formOneLine(lines, "(i) Current account balances of SBI, its subsidiaries and the corresponding new banks",
"FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2);
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Total of other demand liabilites to the banking system",
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system",
"FORM1_I_A_II_OTHER_DEMAND_BS", dates, officeId, 2);
final List<BigDecimal> ib = formOneLine(lines, "(b) Time Liabilities to the banking system", "FORM1_I_B_TIME_BS",
final List<BigDecimal> ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS",
dates, officeId, 1);
final List<BigDecimal> totalI = sumOf(iai, iaii, ib);
lines.add(formOneTotal("TOTAL OF I : >>>>>>>>>>>", totalI));
lines.add(formOneTotal("TOTAL OF I", totalI));
lines.add(formOneHeading("II. LIABILITIES IN INDIA TO OTHERS"));
// Demand and time come from the one DTL head, split by the class each mapped account
@@ -1313,25 +1307,23 @@ public class RbiReserveReturnApiResource {
iiA.add(dtl.demandAmount);
iiB.add(dtl.timeAmount);
}
lines.add(formOneRow("(A) DEMAND LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiA, 1));
lines.add(formOneRow("(B) TIME LIABILITIES :", "DEMAND_TIME_LIABILITIES", iiB, 1));
lines.add(formOneRow("(A) Demand liabilities", "DEMAND_TIME_LIABILITIES", iiA, 1));
lines.add(formOneRow("(B) Time liabilities", "DEMAND_TIME_LIABILITIES", iiB, 1));
final List<BigDecimal> totalII = sumOf(iiA, iiB);
lines.add(formOneTotal("TOTAL OF II : >>>>>>>>>>>", totalII));
lines.add(formOneTotal("TOTAL OF II", totalII));
lines.add(formOneHeading("III. ASSETS IN INDIA WITH THE BANKING SYSTEM"));
final List<BigDecimal> iiiA = formOneLine(lines,
"(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks",
final List<BigDecimal> iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks",
"FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
lines.add(formOneHeading("(B) TOTAL OF OTHER ASSETS WITH THE BANKING SYSTEM, VIZ."));
final List<BigDecimal> iiiBi = formOneLine(lines,
"(i) Balances in all Accounts other than those included in item III (a) :",
lines.add(formOneHeading("(B) Other assets with the banking system"));
final List<BigDecimal> iiiBi = formOneLine(lines, "(i) Balances in all accounts other than III(A)",
"FORM1_III_B_I_OTHER_BALANCES", dates, officeId, 2);
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at Call and Short notice :",
final List<BigDecimal> iiiBii = formOneLine(lines, "(ii) Money at call and short notice",
"FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2);
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances & any other Assets :",
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances and any other assets",
"FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2);
final List<BigDecimal> totalIII = sumOf(iiiA, iiiBi, iiiBii, iiiBiv);
lines.add(formOneTotal("TOTAL OF III : >>>>>>>>>>", totalIII));
lines.add(formOneTotal("TOTAL OF III", totalIII));
// IV: the banking system nets out, and what is left is added to the liabilities to others.
// Floored at zero per column - the form's own wording, "(I - III) + II if minus figure",
@@ -1346,9 +1338,7 @@ public class RbiReserveReturnApiResource {
// requirement is actually charged on. A form showing one IV reads as though the bank had to
// hold a ratio of today's liabilities, which is not what either section says.
lines.add(formOneTotal(
"IV. a) TOTAL (NET) DEMAND & TIME LIABILITIES FOR THE PURPOSE OF SEC.18 & 24 OF THE ACT = (I-III)+II IF MINUS"
+ " FIGURE [CURRENT FORTNIGHT]",
iv));
"IV. a) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv));
final List<BigDecimal> ivBase = new ArrayList<>();
final List<String> ivBaseDates = new ArrayList<>();
for (int i = 0; i < dates.size(); i++) {
@@ -1356,38 +1346,33 @@ public class RbiReserveReturnApiResource {
ivBaseDates.add(base.toString());
ivBase.add(formOneNetLiabilities(base, officeId));
}
// The printed form words this "AS ON THE LAST FRIDAY OF THE SECOND PRECEDING FORTNIGHT",
// which is the Act's rule. This bank does not file on that rule - its base is the matching
// fortnight close one month earlier, which formOneBaseDate computes - so the line says BASE
// DATE and leaves the dates themselves to the column headings. A heading that named a
// Friday the return never used would be a statement nobody could check.
final Map<String, Object> ivBaseRow = formOneTotal(
"IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24",
"IV. b) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]",
ivBase);
ivBaseRow.put("baseDates", ivBaseDates);
lines.add(ivBaseRow);
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0);
final List<BigDecimal> v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0);
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH :"));
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India :", "FORM1_VI_A_RBI_CURRENT", dates,
lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH"));
final List<BigDecimal> viA = formOneLine(lines, "(a) Reserve Bank of India", "FORM1_VI_A_RBI_CURRENT", dates,
officeId, 1);
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co.Op.Bank of the State Concerned :",
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned",
"FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1);
final List<BigDecimal> viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.Concerned :",
final List<BigDecimal> viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned",
"FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1);
final List<BigDecimal> totalVI = sumOf(viA, viB, viC);
lines.add(formOneTotal("TOTAL OF VI : >>>>>>>>>>", totalVI));
lines.add(formOneTotal("TOTAL OF VI", totalVI));
lines.add(formOneHeading("VII. Balance Of all Other types With :"));
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :",
lines.add(formOneHeading("VII. BALANCES OF ALL OTHER TYPES WITH"));
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned",
"FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1);
final List<BigDecimal> viiB = formOneLine(lines, "(B) CENTRAL CO.OP.BANK OF THE DIST.CONCERNED",
final List<BigDecimal> viiB = formOneLine(lines, "(b) District Central Co-operative Bank",
"FORM1_VII_B_DCCB_OTHER", dates, officeId, 1);
final List<BigDecimal> viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY",
final List<BigDecimal> viiC = formOneLine(lines, "(c) Investment in Government securities",
"FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1);
final List<BigDecimal> totalVII = sumOf(viiA, viiB, viiC);
lines.add(formOneTotal("TOTAL OF VII : >>>>>>>>>>", totalVII));
lines.add(formOneTotal("TOTAL OF VII", totalVII));
// VIII: only the excess counts. Where the banking system's claim on this bank exceeds the
// bank's own balance there is nothing to carry into the cash reserve, so it floors at zero.
@@ -1395,7 +1380,7 @@ public class RbiReserveReturnApiResource {
for (int i = 0; i < dates.size(); i++) {
viii.add(iiiA.get(i).subtract(iai.get(i)).max(BigDecimal.ZERO));
}
lines.add(formOneTotal("VIII. Net balance in current accounts i.e.excess of III (a) over I (a) (i) :", viii));
lines.add(formOneTotal("VIII. NET BALANCE IN CURRENT ACCOUNTS — excess of III(A) over I(a)(i)", viii));
// The base liabilities are IV(b) above - it used to be repeated as a row of its own at the
// foot of the part, which put the same figure on the form twice under two different names.
@@ -1432,12 +1417,12 @@ public class RbiReserveReturnApiResource {
// template's own wording says "Last Friday of the Second Preceding fortnight"; this bank's
// base rule is the matching close of the previous month, so the line says IV(b) and leaves
// the base date itself to the column heading rather than claiming a Friday it did not use.
lines.add(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
lines.add(formOneTotal("IX. CRR REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"),
required));
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED := V+VI+VIII", maintained));
lines.add(formOneTotal("X. CASH RESERVE ACTUALLY MAINTAINED (V + VI + VIII)", maintained));
final Map<String, Object> part = new LinkedHashMap<>();
part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)");
part.put("title", "PART B — Compliance with Section 18");
part.put("lines", lines);
part.put("required", required);
part.put("maintained", maintained);
@@ -1460,35 +1445,32 @@ public class RbiReserveReturnApiResource {
: base.multiply(slr.percentage).divide(HUNDRED, 2, RoundingMode.HALF_UP);
required.add(slrRequired);
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the government
// securities of VII(c). Only the excess cash counts: the part of the cash reserve that
// meets section 18 is already committed and cannot also meet section 24.
//
// VII(c) alone, not the whole of VII. VII(a) and VII(b) are balances with the State
// Co-operative Bank and the District Central Co-operative Bank - deposits placed with
// other co-operative banks, which section 24 does not admit as liquid assets. Counting
// them here overstated what the bank held against SLR by the whole of those deposits.
// XII(a) is the cash reserve held OVER the section 18 requirement, plus the other
// balances of part VII. Only the excess counts: the part of the cash reserve that meets
// section 18 is already committed and cannot also meet section 24.
final BigDecimal crrRequired = formOnePartBRequired(date, officeId);
final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId);
final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO);
final BigDecimal governmentSecurities = figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount;
cashAndOther.add(excessCash.add(governmentSecurities));
final BigDecimal otherBalances = figureFor("FORM1_VII_A_STATE_COOP_OTHER", date, officeId).amount
.add(figureFor("FORM1_VII_B_DCCB_OTHER", date, officeId).amount)
.add(figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount);
cashAndOther.add(excessCash.add(otherBalances));
gold.add(figureFor("FORMA_SLR_GOLD", date, officeId).amount);
securities.add(figureFor("SLR_INVESTMENTS", date, officeId).amount);
}
// Charged on IV(b) like the cash reserve above, and named the same way.
lines.add(formOneTotal("XI. " + (slr == null ? "SLR rate not set" : slr.percentage + " percent") + " (or a higher specified percentage) of IV as on the base date",
lines.add(formOneTotal("XI. SLR REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"),
required));
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED :"));
lines.add(formOneRow("(a) Cash and other balances maintained in India i.e. X-IX+VII :", null, cashAndOther, 1));
lines.add(formOneHeading("XII. ASSETS ACTUALLY MAINTAINED"));
lines.add(formOneRow("(a) Cash and other balances maintained in India (X − IX + VII)", null, cashAndOther, 1));
lines.add(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
lines.add(formOneRow("(c) Unencombered approved Securites $$", "SLR_INVESTMENTS", securities, 1));
lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1));
final List<BigDecimal> totalXII = sumOf(cashAndOther, gold, securities);
lines.add(formOneTotal("Total of XII.....................................", totalXII));
lines.add(formOneTotal("TOTAL OF XII", totalXII));
final Map<String, Object> part = new LinkedHashMap<>();
part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)");
part.put("title", "PART C — Compliance with Section 24");
part.put("lines", lines);
part.put("required", required);
part.put("maintained", totalXII);
@@ -361,9 +361,6 @@ public class RecurringDepositAccountsApiResource {
} else if (is(commandParam, "close")) {
final CommandWrapper commandRequest = builder.closeRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "extendTenure")) {
final CommandWrapper commandRequest = builder.extendTenureRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseRecurringDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
@@ -380,7 +377,7 @@ public class RecurringDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount", "extendTenure" });
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -1,205 +0,0 @@
/**
* 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.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* RD Dashboard's data source: every Recurring Deposit still running (ACTIVE, or MATURED and waiting to be closed) in
* the user's office hierarchy, each with its installment position worked out as of the business date, plus the
* month's closures and penalty income for the summary cards.
*
* <p>
* An installment counts as paid by the deposit's cumulative deposits, not by the schedule's own completed flag - the
* same rule {@code RecurringDepositMissedInstallmentPenaltyService} uses, since money paid in advance at the counter is
* not always allocated to the installment it covers. The dashboard and the penalty job therefore agree on which
* installments are overdue.
*/
@Path("/v1/recurringdepositaccounts/dashboard")
@Component
@Tag(name = "Recurring Deposit Accounts", description = "RD Dashboard: running RDs with their installment position.")
@RequiredArgsConstructor
public class RecurringDepositDashboardApiResource {
/** DepositAccountType.RECURRING_DEPOSIT. */
private static final int RECURRING_DEPOSIT = 300;
/** SavingsAccountStatusType values. */
private static final int ACTIVE = 300;
private static final int CLOSED = 600;
private static final int PRE_MATURE_CLOSURE = 700;
private static final int MATURED = 800;
/** SavingsAccountTransactionType.DEPOSIT. */
private static final int DEPOSIT = 1;
private static final String ROWS_SQL = """
WITH rd AS (
SELECT sa.id, sa.account_no, sa.client_id, sa.product_id, sa.status_enum, sa.account_balance_derived,
sa.auto_debit_facility, sa.linked_account_no, c.display_name AS client_name, o.id AS office_id,
o.name AS office_name
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum IN (?, ?) AND o.hierarchy LIKE ?
),
paid AS (
SELECT t.savings_account_id AS id, SUM(t.amount) AS total_paid,
SUM(t.amount) FILTER (WHERE t.transaction_date BETWEEN ? AND ?) AS paid_this_month
FROM m_savings_account_transaction t
JOIN rd ON rd.id = t.savings_account_id
WHERE t.transaction_type_enum = ? AND t.is_reversed = false
GROUP BY t.savings_account_id
),
sched AS (
SELECT s.savings_account_id AS id, s.duedate, s.deposit_amount,
SUM(s.deposit_amount) OVER (PARTITION BY s.savings_account_id ORDER BY s.duedate, s.installment) AS cum_due
FROM m_mandatory_savings_schedule s
JOIN rd ON rd.id = s.savings_account_id
),
pos AS (
SELECT sc.id,
COUNT(*) AS total_installments,
COUNT(*) FILTER (WHERE sc.cum_due <= COALESCE(p.total_paid, 0)) AS paid_installments,
MIN(sc.duedate) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0)) AS next_unpaid_due,
COUNT(*) FILTER (WHERE sc.cum_due > COALESCE(p.total_paid, 0) AND sc.duedate < ?) AS overdue_installments,
GREATEST(COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate < ?), 0) - COALESCE(MAX(p.total_paid), 0), 0)
AS overdue_amount,
COUNT(*) FILTER (WHERE sc.duedate = ?) AS due_today_count,
COUNT(*) FILTER (WHERE sc.duedate = ? AND sc.cum_due <= COALESCE(p.total_paid, 0)) AS due_today_paid,
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?), 0) AS due_this_month,
COALESCE(SUM(sc.deposit_amount) FILTER (WHERE sc.duedate BETWEEN ? AND ?
AND sc.cum_due <= COALESCE(p.total_paid, 0)), 0) AS received_this_month
FROM sched sc
LEFT JOIN paid p ON p.id = sc.id
GROUP BY sc.id
),
pen AS (
SELECT pe.savings_account_id AS id, COUNT(*) AS penalty_count, SUM(pe.penalty_amount) AS penalty_amount
FROM m_rd_installment_penalty pe
JOIN rd ON rd.id = pe.savings_account_id
GROUP BY pe.savings_account_id
),
lien AS (
SELECT paa.linked_savings_account_id AS id, SUM(COALESCE(gfd.amount_remaining_derived, gfd.amount)) AS lien_amount
FROM m_guarantor_funding_details gfd
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
JOIN m_loan l ON l.id = paa.loan_account_id
JOIN rd ON rd.id = paa.linked_savings_account_id
WHERE gfd.status_enum = 100 AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
GROUP BY paa.linked_savings_account_id
)
SELECT rd.id AS "id", rd.account_no AS "accountNo", rd.client_id AS "clientId", rd.client_name AS "clientName",
rd.office_id AS "officeId", rd.office_name AS "officeName", sp.id AS "productId", sp.name AS "productName",
rd.status_enum AS "status", rd.account_balance_derived AS "balance",
rdd.mandatory_recommended_deposit_amount AS "installmentAmount",
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate", datp.maturity_amount AS "maturityAmount",
COALESCE(rd.auto_debit_facility, false) AS "autoDebit", rd.linked_account_no AS "linkedAccountNo",
COALESCE(sp.missed_installment_grace_days, 0) AS "graceDays",
COALESCE(sp.penalty_grace_period_missed_installments, false) AS "penaltyEnabled",
COALESCE(pos.total_installments, 0) AS "totalInstallments",
COALESCE(pos.paid_installments, 0) AS "paidInstallments",
TO_CHAR(pos.next_unpaid_due, 'YYYY-MM-DD') AS "nextDueDate",
COALESCE(pos.overdue_installments, 0) AS "overdueInstallments",
COALESCE(pos.overdue_amount, 0) AS "overdueAmount",
COALESCE(pos.due_today_count, 0) AS "dueTodayCount",
COALESCE(pos.due_today_paid, 0) AS "dueTodayPaid",
COALESCE(pos.due_this_month, 0) AS "dueThisMonth",
COALESCE(pos.received_this_month, 0) AS "receivedThisMonth",
COALESCE(p.paid_this_month, 0) AS "paidThisMonth",
COALESCE(pen.penalty_count, 0) AS "penaltyCount",
COALESCE(pen.penalty_amount, 0) AS "penaltyAmount",
COALESCE(lien.lien_amount, 0) AS "lienAmount"
FROM rd
JOIN m_savings_product sp ON sp.id = rd.product_id
LEFT JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = rd.id
LEFT JOIN m_deposit_account_recurring_detail rdd ON rdd.savings_account_id = rd.id
LEFT JOIN pos ON pos.id = rd.id
LEFT JOIN paid p ON p.id = rd.id
LEFT JOIN pen ON pen.id = rd.id
LEFT JOIN lien ON lien.id = rd.id
ORDER BY pos.next_unpaid_due NULLS LAST, rd.account_no
""";
/** RDs closed this month, split by how they ended. */
private static final String CLOSED_THIS_MONTH_SQL = """
SELECT COUNT(*) FILTER (WHERE sa.status_enum = ?) AS matured_closed,
COUNT(*) FILTER (WHERE sa.status_enum = ?) AS premature_closed
FROM m_savings_account sa
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE sa.deposit_type_enum = ? AND sa.closedon_date BETWEEN ? AND ? AND o.hierarchy LIKE ?
""";
/** Missed-installment penalties charged, this month and in all, on RDs in the hierarchy (closed ones included). */
private static final String PENALTY_INCOME_SQL = """
SELECT COALESCE(SUM(pe.penalty_amount) FILTER (WHERE pe.applied_on_date BETWEEN ? AND ?), 0) AS this_month,
COALESCE(SUM(pe.penalty_amount), 0) AS total
FROM m_rd_installment_penalty pe
JOIN m_savings_account sa ON sa.id = pe.savings_account_id
JOIN m_client c ON c.id = sa.client_id
JOIN m_office o ON o.id = c.office_id
WHERE o.hierarchy LIKE ?
""";
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve() {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final String hierarchy = this.context.authenticatedUser().getOffice().getHierarchy() + "%";
// "Today" is the business date - the branch's working day, not the server clock.
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate monthStart = today.withDayOfMonth(1);
final LocalDate monthEnd = today.withDayOfMonth(today.lengthOfMonth());
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy,
monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd);
final Map<String, Object> closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE,
RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy);
final Map<String, Object> penalty = this.jdbcTemplate.queryForMap(PENALTY_INCOME_SQL, monthStart, monthEnd, hierarchy);
final Map<String, Object> result = new LinkedHashMap<>();
result.put("businessDate", today.toString());
result.put("maturedClosedThisMonth", closed.get("matured_closed"));
result.put("prematureClosedThisMonth", closed.get("premature_closed"));
result.put("penaltyIncomeThisMonth", penalty.get("this_month"));
result.put("penaltyIncomeTotal", penalty.get("total"));
result.put("accounts", rows);
return this.gson.toJson(result);
}
}
@@ -1,80 +0,0 @@
/**
* 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.savings.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.springframework.stereotype.Component;
/**
* Read-only: the loans a recurring deposit currently secures and how much of it each one holds, plus how much more
* could be given as loan against it today, so the RD screens can show the lien, its limit, and why the deposit cannot
* be closed.
*/
@Path("/v1/recurringdepositaccounts/{accountId}/liens")
@Component
@Tag(name = "Recurring Deposit Account", description = "Liens on a recurring deposit pledged as loan security.")
@RequiredArgsConstructor
public class RecurringDepositLienApiResource {
private final PlatformSecurityContext context;
private final RecurringDepositLienService recurringDepositLienService;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve(@PathParam("accountId") final Long accountId) {
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final List<RecurringDepositLienService.Lien> liens = this.recurringDepositLienService.activeLiens(accountId);
final Map<String, Object> result = new LinkedHashMap<>();
final BigDecimal totalLien = liens.stream().map(RecurringDepositLienService.Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
result.put("totalLienAmount", totalLien);
final RecurringDepositLienService.LienLimit limit = this.recurringDepositLienService.lienLimit(accountId);
if (limit != null) {
result.put("lienAllowed", limit.lienAllowed());
result.put("lienPercentage", limit.accountPercentage());
result.put("productLienPercentage", limit.productPercentage());
result.put("effectiveLienPercentage", limit.effectivePercentage());
result.put("maxLoanAmount", limit.maxLoanAmount());
result.put("availableForLoan", limit.maxLoanAmount().subtract(totalLien).max(BigDecimal.ZERO));
}
result.put("liens", liens.stream().map(lien -> {
final Map<String, Object> row = new LinkedHashMap<>();
row.put("loanId", lien.loanId());
row.put("loanAccountNo", lien.loanAccountNo());
row.put("loanStatus", lien.loanStatus());
row.put("amount", lien.amount());
return row;
}).toList());
return this.gson.toJson(result);
}
}
@@ -180,7 +180,6 @@ public class DepositProductDataValidator {
}
validateRecurringDetailForCreate(element, baseDataValidator);
validateLienPercentage(element, baseDataValidator);
if (!hasRdSlabs) {
validateChartsData(element, baseDataValidator);
@@ -216,7 +215,6 @@ public class DepositProductDataValidator {
}
validateRecurringDepositUpdate(element, baseDataValidator);
validateLienPercentage(element, baseDataValidator);
// RD products now use the CBS base chart as a read-only template, so
// product updates no longer require a charts payload.
@@ -227,40 +225,6 @@ public class DepositProductDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/** Lien Marked's loan limit: a share of the deposit's balance, so above 0 and at most 100. Optional. */
private void validateLienPercentage(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
if (!this.fromApiJsonHelper.parameterExists("lienPercentage", element)) {
return;
}
final BigDecimal lienPercentage = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("lienPercentage", element);
baseDataValidator.reset().parameter("lienPercentage").value(lienPercentage).ignoreIfNull().positiveAmount()
.notGreaterThanMax(BigDecimal.valueOf(100));
validateMissedInstallmentPenalty(element, baseDataValidator);
}
/**
* Missed-installment penalty: grace days are whole days from 0, the type is 1 (flat amount) or 2 (% of the
* installment), and the amount is positive - at most 100 when it is a percentage. All optional.
*/
private void validateMissedInstallmentPenalty(final JsonElement element, final DataValidatorBuilder baseDataValidator) {
if (this.fromApiJsonHelper.parameterExists("missedInstallmentGraceDays", element)) {
final Integer graceDays = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentGraceDays", element);
baseDataValidator.reset().parameter("missedInstallmentGraceDays").value(graceDays).ignoreIfNull().integerZeroOrGreater();
}
Integer penaltyType = null;
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyType", element)) {
penaltyType = this.fromApiJsonHelper.extractIntegerWithLocaleNamed("missedInstallmentPenaltyType", element);
baseDataValidator.reset().parameter("missedInstallmentPenaltyType").value(penaltyType).ignoreIfNull().isOneOfTheseValues(1, 2);
}
if (this.fromApiJsonHelper.parameterExists("missedInstallmentPenaltyAmount", element)) {
final BigDecimal amount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed("missedInstallmentPenaltyAmount", element);
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).ignoreIfNull().positiveAmount();
if (amount != null && Integer.valueOf(2).equals(penaltyType)) {
baseDataValidator.reset().parameter("missedInstallmentPenaltyAmount").value(amount).notGreaterThanMax(BigDecimal.valueOf(100));
}
}
}
private void validateDepositDetailForCreate(final JsonElement element, final FromJsonHelper fromApiJsonHelper,
final DataValidatorBuilder baseDataValidator, final DepositAccountType accountType) {
final String name = fromApiJsonHelper.extractStringNamed(nameParamName, element);
@@ -67,7 +67,4 @@ public interface DepositAccountDomainService {
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
/** Names the journal entries of the recurring deposit's interest credits that have no narration yet. */
void narrateRdInterestPostings(RecurringDepositAccount account);
}
@@ -72,7 +72,6 @@ import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.PrematureClosureReasonType;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -448,47 +447,14 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// Checked before anything is posted or paid out, so a wrong date is reported as such.
account.validateMaturityClosureDate(closedDate);
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, closedDate, postReversals);
// Paid out late: the days between maturity and today earn the product's post-maturity rate, so every closure
// branch below - reinvest, transfer or withdraw - carries it.
account.postOverdueMaturityInterest(closedDate, postReversals);
final BigDecimal transactionAmount = account.getAccountBalance();
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
// getDepositAmount() is the deposit's contracted total (every installment it was meant to receive),
// not what actually came in. Capped at the balance, or a deposit with missed installments - or none -
// would open its successor with money that was never paid.
reInvestAmount = account.getDepositAmount() == null ? account.getAccountBalance()
: account.getDepositAmount().min(account.getAccountBalance());
// Principal only: the interest part does not go into the new deposit, so it has to be paid
// somewhere real first - the savings account named on the request, else the one chosen when
// the deposit was opened, else its linked savings account. Previously the full balance was
// withdrawn while only the principal was redeposited, so the interest simply vanished.
final BigDecimal interestPart = account.getAccountBalance().subtract(reInvestAmount);
if (interestPart.signum() > 0) {
final Long interestTargetId = resolveRdPayoutSavingsId(account,
command.longValueOfParameterNamed(toSavingsAccountIdParamName));
if (interestTargetId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.reinvest.principal.needs.savings",
"Reinvest Principal Only pays the interest out to a savings account - choose one, or link a Savings account to this deposit.");
}
final SavingsAccount interestTarget = this.depositAccountAssembler.assembleFrom(interestTargetId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final String description = "RD " + account.getAccountNumber() + " interest to savings "
+ interestTarget.getAccountNumber() + " (maturity, principal reinvested)";
final AccountTransferDTO interestTransfer = new AccountTransferDTO(closedDate, interestPart, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null, null,
AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, interestTarget, account,
isRegularTransaction, isExceptionForBalanceCheck);
narrateTransfer(this.accountTransfersWritePlatformService.transferFunds(interestTransfer), description);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
}
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
@@ -504,36 +470,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
frequency = frequency == -1 ? 1 : frequency;
reinvestedDeposit.generateSchedule(frequencyType, frequency, calendar);
reinvestedDeposit.processAccountUponActivation(fmt, postReversals, relaxingDaysConfigForPivotDate);
// The opening credit is on the new deposit but its stored balance was never recomputed, so it read 0
// until something else touched the account.
reinvestedDeposit.updateSavingsAccountSummary(reinvestedDeposit.getTransactions());
reinvestedDeposit.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
this.savingsAccountRepository.save(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// The money moves deposit-to-deposit and never touches cash: the new deposit's opening credit and the
// old deposit's withdrawal below both post against transfers-in-suspense, which nets to nil. Before
// this the opening credit posted no journal entry at all and the withdrawal posted as cash paid out,
// leaving the RD control GL short and cash overstated by the whole reinvested amount.
final boolean isInternalTransferLeg = true;
postJournalEntries(reinvestedDeposit, new HashSet<>(), new HashSet<>(), isInternalTransferLeg);
final boolean applyWithdrawFee = false;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, applyWithdrawFee, isRegularTransaction, isInternalTransferLeg);
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// The withdrawal posted journal entries for everything new on the account (the maturity interest
// included), so every transaction now on it is accounted for.
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "maturity", transferDescription, locale, fmt,
isRegularTransaction);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, transactionAmount,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
} else {
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
@@ -549,28 +505,10 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
narrateRdInterestPostings(account);
return savingsTransactionId;
}
/**
* Where a recurring deposit's proceeds may be paid when no account was named for this closure: the savings
* account chosen when it was opened, else its own linked Savings account (Deposit Account Link).
*/
private Long resolveRdPayoutSavingsId(final RecurringDepositAccount account, final Long requestedSavingsId) {
if (requestedSavingsId != null) {
return requestedSavingsId;
}
if (account.getTransferToSavingsAccountId() != null) {
return account.getTransferToSavingsAccountId();
}
if (account.getLinkedAccountId() != null && "SAVINGS".equalsIgnoreCase(account.getLinkedAccountType())) {
return account.getLinkedAccountId();
}
return null;
}
private CalendarInstance getCalendarInstance(final RecurringDepositAccount account, RecurringDepositAccount reinvestedDeposit) {
CalendarInstance calendarInstance = null;
CalendarInstance parentCalendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(
@@ -1259,9 +1197,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
Long savingsTransactionId = null;
// The reason decides the penalty (and, for a deceased holder, lock-in) before any interest is worked out.
account.applyPrematureClosureReason(
PrematureClosureReasonType.fromInt(command.integerValueOfParameterNamed(prematureClosureReasonIdParamName)));
// post interest
account.postPreMaturityInterest(closedDate, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postReversals);
@@ -1270,12 +1205,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
DepositAccountOnClosureType closureType = DepositAccountOnClosureType.fromInt(closureTypeValue);
if (closureType.isTransferToSavings()) {
final boolean isExceptionForBalanceCheck = false;
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "premature closure", transferDescription, locale, fmt,
isRegularTransaction);
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
} else {
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
@@ -1289,32 +1228,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.prematureClosure(user, command, changes);
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
narrateRdInterestPostings(account);
return savingsTransactionId;
}
/**
* Names the journal entries of every interest credit on the recurring deposit that has none yet - the term's
* interest posted at maturity or premature closure, and any post-maturity interest - so the ledger shows whose
* interest it is. Called once the closure's own entries are posted, which is the last of them to be written.
*/
@Override
public void narrateRdInterestPostings(final RecurringDepositAccount account) {
final String narration = "RD " + account.getAccountNumber() + " interest credited";
narrateJournalEntries("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = false
""", narration, account.getId(), SavingsAccountTransactionType.INTEREST_POSTING.getValue());
// An interest credit taken back (a premature closure, or a maturity whose final rate came out lower, credits
// the recalculated interest instead): its original entries are already named, so this names only the reversing
// entries.
narrateJournalEntries("""
SELECT id FROM m_savings_account_transaction
WHERE savings_account_id = ? AND transaction_type_enum = ? AND is_reversed = true
""", "RD " + account.getAccountNumber() + " interest reversed - recalculated on closure", account.getId(),
SavingsAccountTransactionType.INTEREST_POSTING.getValue());
}
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(account.findExistingTransactionIds());
@@ -1331,71 +1247,6 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
}
/**
* Pays a closing recurring deposit into a savings account as two credits - its interest, then the principal -
* each described with the deposit's account number, so the savings statement shows which RD the money came from
* and how much of it is interest.
*
* <p>
* The interest goes first and as an interest transfer: TDS counts as interest only what a payout carries beyond
* the interest already swept out of the deposit, so with the interest swept first the principal transfer after it
* is never mistaken for interest, and the interest is tracked for TDS on its own credit.
*/
private void transferRdPayoutToSavings(final RecurringDepositAccount account, final SavingsAccount toSavingsAccount,
final LocalDate closedDate, final String closureLabel, final String clerkNote, final Locale locale,
final DateTimeFormatter fmt, final boolean isRegularTransaction) {
final boolean isExceptionForBalanceCheck = false;
final String note = clerkNote == null || clerkNote.isBlank() ? "" : " - " + clerkNote.trim();
final String rd = "RD " + account.getAccountNumber();
final BigDecimal interest = account.getInterestInBalance();
final BigDecimal principal = account.getAccountBalance().subtract(interest);
if (interest.signum() > 0) {
final String description = rd + " interest to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")" + note;
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, interest,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
null, AccountTransferType.INTEREST_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isRegularTransaction, isExceptionForBalanceCheck));
narrateTransfer(transferId, description);
}
if (principal.signum() > 0) {
final String description = rd + " principal to savings " + toSavingsAccount.getAccountNumber() + " (" + closureLabel + ")"
+ note;
final Long transferId = this.accountTransfersWritePlatformService.transferFunds(new AccountTransferDTO(closedDate, principal,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, description, locale, fmt, null, null, null, null,
null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isRegularTransaction, isExceptionForBalanceCheck));
narrateTransfer(transferId, description);
}
}
/**
* Writes a narration onto the journal entries of both sides of a transfer. The entries are posted while the
* transfer is still being made, before its own record - and so its description - exists, so they are named here
* once it does.
*/
private void narrateTransfer(final Long transferDetailsId, final String narration) {
if (transferDetailsId == null) {
return;
}
narrateJournalEntries("""
SELECT from_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
UNION SELECT to_savings_transaction_id FROM m_account_transfer_transaction WHERE account_transfer_details_id = ?
""", narration, transferDetailsId, transferDetailsId);
}
/** Sets the narration of the not-yet-narrated journal entries of the savings transactions the query returns. */
private void narrateJournalEntries(final String transactionIdsSql, final String narration, final Object... args) {
final List<Long> transactionIds = this.jdbcTemplate.queryForList(transactionIdsSql, Long.class, args);
for (final Long transactionId : transactionIds) {
if (transactionId != null) {
this.jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET description = ? WHERE savings_transaction_id = ? AND (description IS NULL OR description = '')",
narration, transactionId);
}
}
}
/** How a closure that does not go to savings paid out, for the TDS tracking record. */
private static String directPayoutKind(final DepositAccountOnClosureType closureType) {
return closureType != null && closureType.isTransferToGl() ? "GL" : "CASH";
@@ -1,43 +0,0 @@
/**
* 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.savings.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.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
@Service
@RequiredArgsConstructor
@CommandType(entity = "RECURRINGDEPOSITACCOUNT", action = "EXTENDTENURE")
public class ExtendTenureRecurringDepositAccountCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.extendRecurringDepositTenure(command.entityId(), command);
}
}
@@ -1,62 +0,0 @@
/**
* 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.savings.jobs.applyrdmissedinstallmentpenalty;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.savings.service.RecurringDepositMissedInstallmentPenaltyService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.repeat.RepeatStatus;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
/**
* Runs the RD missed-installment penalty. The service decides what is due and isolates each installment, so the step
* is a thin trigger and the rules live in one place.
*/
@Configuration
public class ApplyRdMissedInstallmentPenaltyJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RecurringDepositMissedInstallmentPenaltyService recurringDepositMissedInstallmentPenaltyService;
@Bean
protected Step applyRdMissedInstallmentPenaltyStep() {
return new StepBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).tasklet((contribution, chunkContext) -> {
recurringDepositMissedInstallmentPenaltyService.applyPenalties();
return RepeatStatus.FINISHED;
}, transactionManager).build();
}
@Bean
public Job applyRdMissedInstallmentPenaltyJob() {
return new JobBuilder(JobName.APPLY_RD_MISSED_INSTALLMENT_PENALTY.name(), jobRepository).start(applyRdMissedInstallmentPenaltyStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -1,60 +0,0 @@
/**
* 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.savings.jobs.executerdautodebit;
import org.apache.fineract.infrastructure.jobs.service.JobName;
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;
@Configuration
public class ExecuteRdAutoDebitJobConfig {
@Autowired
private JobRepository jobRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@Autowired
private RecurringDepositAutoDebitService recurringDepositAutoDebitService;
@Bean
public ExecuteRdAutoDebitTasklet executeRdAutoDebitTasklet() {
return new ExecuteRdAutoDebitTasklet(recurringDepositAutoDebitService);
}
@Bean
protected Step executeRdAutoDebitStep() {
return new StepBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).tasklet(executeRdAutoDebitTasklet(), transactionManager)
.build();
}
@Bean
public Job executeRdAutoDebitJob() {
return new JobBuilder(JobName.EXECUTE_RD_AUTO_DEBIT.name(), jobRepository).start(executeRdAutoDebitStep())
.incrementer(new RunIdIncrementer()).build();
}
}
@@ -1,42 +0,0 @@
/**
* 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.savings.jobs.executerdautodebit;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.savings.service.RecurringDepositAutoDebitService;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;
/**
* Runs RD Auto Debit. The service decides what is due and isolates each installment, so the tasklet stays a thin
* trigger and the rules live in one place.
*/
@RequiredArgsConstructor
public class ExecuteRdAutoDebitTasklet implements Tasklet {
private final RecurringDepositAutoDebitService service;
@Override
public RepeatStatus execute(final StepContribution contribution, final ChunkContext chunkContext) {
service.collectDueInstallments();
return RepeatStatus.FINISHED;
}
}
@@ -61,16 +61,6 @@ public class UpdateDepositsAccountMaturityDetailsTasklet implements Tasklet {
}
}
// Recurring deposits still running: credit any posting period (monthly, quarterly... per product) that ended
// before today. The query above only returns deposits that have reached maturity.
for (final Long rdId : depositAccountReadPlatformService.retrieveRunningRecurringDepositIds()) {
try {
depositAccountWritePlatformService.postRecurringDepositPeriodicInterest(rdId);
} catch (final Exception ex) {
log.error("Periodic interest posting failed for recurring deposit id {}", rdId, ex);
}
}
log.debug("{}: Records affected by updateMaturityDetailsOfDepositAccounts: {}", ThreadLocalContextUtil.getTenant().getName(),
depositAccounts.size());
return RepeatStatus.FINISHED;
@@ -46,7 +46,6 @@ import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
@RequiredArgsConstructor
public class DepositAccountPreMatureCalculationPlatformServiceImpl implements DepositAccountPreMatureCalculationPlatformService {
@@ -118,19 +117,12 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
paymentTypeOptions, savingsAccountDatas, fd.getNominalAnnualInterestRate());
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rd = (RecurringDepositAccount) account;
// Same reason rules the real closure applies, so the preview pays exactly what the closure will.
rd.applyPrematureClosureReason(prematureClosureReason);
accountData = RecurringDepositAccountData.preClosureDetails(
account.getId(), rd.calculatePreMatureAmount(interestCalculatedToDate, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth),
onAccountClosureOptions, paymentTypeOptions, savingsAccountDatas);
}
// A preview, not a closure: working the payout out resets the account's own interest rate to the premature
// rate and refreshes its interest-earned summary on the managed entity, and committing that left an RD that
// was only previewed showing the penalty rate (4.5% instead of 6.5%) as if it had been broken. Nothing the
// calculation touched may be saved.
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
return accountData;
}
}
@@ -61,9 +61,6 @@ public interface DepositAccountReadPlatformService {
Collection<DepositAccountData> retrieveForMaturityUpdate();
/** Active recurring deposits that have not reached maturity yet - their posting periods are credited nightly. */
Collection<Long> retrieveRunningRecurringDepositIds();
SavingsAccountTransactionData retrieveRecurringAccountDepositTransactionTemplate(Long accountId);
Collection<AccountTransferDTO> retrieveDataForInterestTransfer();
@@ -283,18 +283,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
return this.jdbcTemplate.query(sqlBuilder.toString(), DEPOSIT_ACCOUNT_LOOKUP_MAPPER, depositAccountType.getValue());
}
@Override
public Collection<Long> retrieveRunningRecurringDepositIds() {
return this.jdbcTemplate.queryForList("""
SELECT da.id FROM m_savings_account da
JOIN m_deposit_account_term_and_preclosure dat ON dat.savings_account_id = da.id
WHERE da.deposit_type_enum = ? AND da.status_enum = ?
AND (dat.maturity_date IS NULL OR dat.maturity_date > ?)
ORDER BY da.id
""", Long.class, DepositAccountType.RECURRING_DEPOSIT.getValue(), SavingsAccountStatusType.ACTIVE.getValue(),
DateUtils.getBusinessLocalDate());
}
@Override
public Collection<DepositAccountData> retrieveForMaturityUpdate() {
@@ -2069,8 +2057,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
sqlBuilder.append("dptp.max_deposit_term_type_enum as maxDepositTermTypeId, ");
sqlBuilder.append("dptp.in_multiples_of_deposit_term as inMultiplesOfDepositTerm, ");
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
// Lien Marked's loan limit, so the opening screen can default to it and refuse anything above it.
sqlBuilder.append("sa.lien_percentage as lienPercentage, ");
sqlBuilder.append("sa.account_type as accountType, sa.customer_segment as customerSegment, ");
sqlBuilder.append("sa.product_code as productCode, ");
sqlBuilder.append("sa.effective_date as effectiveDate, sa.effective_to as effectiveTo, sa.end_date as endDate, ");
@@ -2197,7 +2183,6 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
rs.getBoolean("deceasedSettlement"),
rs.getBoolean("allowDebitTxnAfterDueDate")
);
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
return result;
}
@@ -24,7 +24,6 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.amountPa
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargeIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.dueAsOfDateParamName;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -45,7 +44,6 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
@@ -149,8 +147,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final JdbcTemplate jdbcTemplate;
private final RecurringDepositLienService recurringDepositLienService;
private final RecurringDepositProductRules recurringDepositProductRules;
@Transactional
@Override
@@ -643,12 +639,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
checkClientOrGroupActive(account);
if (account instanceof RecurringDepositAccount rdAccount) {
// A recurring deposit is credited by its posting periods only - the same credits the nightly job makes.
postRecurringDepositPeriodicInterest(rdAccount);
} else {
postInterest(account);
}
postInterest(account);
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
@@ -659,28 +650,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public void postRecurringDepositPeriodicInterest(final Long recurringDepositId) {
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(recurringDepositId,
DepositAccountType.RECURRING_DEPOSIT);
postRecurringDepositPeriodicInterest(account);
}
/**
* Credits a recurring deposit's completed posting periods (monthly, quarterly... per its product) with their own
* journal entries, named after the deposit.
*/
private void postRecurringDepositPeriodicInterest(final RecurringDepositAccount rdAccount) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(rdAccount, existingTransactionIds, existingReversedTransactionIds);
rdAccount.postPeriodicInterest(this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth(), false);
this.savingAccountRepositoryWrapper.saveAndFlush(rdAccount);
postJournalEntries(rdAccount, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
}
private void postInterest(final SavingsAccount account) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
@@ -962,7 +931,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed");
this.depositAccountDomainService.handleRDAccountClosure(account, paymentDetail, user, command, changes);
@@ -1079,43 +1047,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Transactional
@Override
public CommandProcessingResult extendRecurringDepositTenure(final Long savingsId, final JsonCommand command) {
this.context.authenticatedUser();
final Integer newDepositPeriod = command.integerValueOfParameterNamed(DepositsApiConstants.depositPeriodParamName);
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
// The added installments follow the deposit's own collection calendar (monthly unless it was opened otherwise).
PeriodFrequencyType frequencyType = PeriodFrequencyType.MONTHS;
Integer frequency = 1;
final CalendarInstance calendarInstance = this.calendarInstanceRepository.findByEntityIdAndEntityTypeIdAndCalendarTypeId(savingsId,
CalendarEntityType.SAVINGS.getValue(), CalendarType.COLLECTION.getValue());
if (calendarInstance != null && calendarInstance.getCalendar() != null) {
final String recurrence = calendarInstance.getCalendar().getRecurrence();
frequencyType = CalendarFrequencyType.from(CalendarUtils.getFrequency(recurrence));
frequency = CalendarUtils.getInterval(recurrence);
frequency = frequency == -1 ? 1 : frequency;
}
final Map<String, Object> changes = account.extendTenure(newDepositPeriod, frequencyType, frequency,
this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd(),
this.configurationDomainService.retrieveFinancialYearBeginningMonth());
this.recurringDepositProductRules.validateMaturityWithinProductEndDate(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes) //
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1128,7 +1059,6 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final RecurringDepositAccount account = (RecurringDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.RECURRING_DEPOSIT);
checkClientOrGroupActive(account);
this.recurringDepositLienService.assertNoActiveLien(savingsId, "closed prematurely");
if (account.maturityDate() == null) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
@@ -1659,76 +1589,11 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
}
} else if (depositAccountType.isRecurringDeposit()) {
final RecurringDepositAccount rdAccount = (RecurringDepositAccount) account;
// Every night: credit any posting period (the product's Interest Posting Period) that ended before today.
rdAccount.postPeriodicInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
rdAccount.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postReversals);
final JsonCommand maturityClosure = rdMaturityInstructionCommand(rdAccount);
if (maturityClosure != null) {
// Same order as the FD branch above: give the maturity interest its own journal entry first and
// mark it posted, so neither the closure's withdrawal nor the final call below posts it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.handleRDAccountClosure(rdAccount, null, this.context.authenticatedUser(),
maturityClosure, new HashMap<>());
// The closure posted its own entries for everything it created.
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
}
((RecurringDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth, postReversals);
}
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
if (account instanceof RecurringDepositAccount rdAccount) {
// The term's interest credited on the maturity date, named in the ledger as soon as it is posted.
this.depositAccountDomainService.narrateRdInterestPostings(rdAccount);
}
}
/**
* The close command that carries out a recurring deposit's maturity instruction tonight, or null when it must
* wait for staff instead. It runs only when the product has Maturity Instructions on, the deposit has just
* matured, it was opened with Transfer to Savings or a Reinvest option, nothing holds a lien on it, and - for a
* transfer - there is a savings account to pay into (the one chosen at opening, else the linked Savings account).
* Anything else stays Matured, earning the post-maturity rate until someone closes it.
*/
private JsonCommand rdMaturityInstructionCommand(final RecurringDepositAccount rdAccount) {
if (!rdAccount.isMatured() || rdAccount.savingsProduct() == null || !rdAccount.savingsProduct().isMaturityInstructions()) {
return null;
}
final DepositAccountOnClosureType instruction = DepositAccountOnClosureType.fromInt(rdAccount.getOnAccountClosureId());
if (!(instruction.isTransferToSavings() || instruction.isReinvest()) || instruction.isPartialReinvest()) {
return null;
}
if (rdAccount.getAccountBalance() == null || rdAccount.getAccountBalance().signum() <= 0) {
// Nothing was ever paid in: there is nothing to transfer or reinvest, so a person has to look at it.
log.info("RD {} matured with no balance; maturity instruction left for staff", rdAccount.getAccountNumber());
return null;
}
if (!this.recurringDepositLienService.activeLiens(rdAccount.getId()).isEmpty()) {
log.info("RD {} matured with a lien on it; maturity instruction left for staff", rdAccount.getAccountNumber());
return null;
}
Long payoutSavingsId = rdAccount.getTransferToSavingsAccountId();
if (payoutSavingsId == null && rdAccount.getLinkedAccountId() != null
&& "SAVINGS".equalsIgnoreCase(rdAccount.getLinkedAccountType())) {
payoutSavingsId = rdAccount.getLinkedAccountId();
}
// Transfer always needs a destination; Reinvest Principal Only needs one for the interest part.
if (payoutSavingsId == null && (instruction.isTransferToSavings() || instruction.isReinvestPrincipal())) {
log.warn("RD {} matured with instruction {} but no savings account to pay into; left for staff", rdAccount.getAccountNumber(),
instruction.getCode());
return null;
}
final JsonObject closure = new JsonObject();
closure.addProperty(SavingsApiConstants.closedOnDateParamName, rdAccount.maturityDate().toString());
closure.addProperty(SavingsApiConstants.dateFormatParamName, "yyyy-MM-dd");
closure.addProperty(SavingsApiConstants.localeParamName, "en");
closure.addProperty(DepositsApiConstants.onAccountClosureIdParamName, instruction.getValue());
if (payoutSavingsId != null) {
closure.addProperty(DepositsApiConstants.toSavingsAccountIdParamName, payoutSavingsId);
}
closure.addProperty(DepositsApiConstants.transferDescriptionParamName, "Apply maturity instructions");
return JsonCommand.fromJsonElement(rdAccount.getId(), closure, new FromJsonHelper());
}
@Transactional
@@ -127,7 +127,6 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
private final BusinessEventNotifierService businessEventNotifierService;
private final SavingsAccountAssembler savingsAccountAssembler;
private final ClientNomineeWritePlatformService clientNomineeWritePlatformService;
private final RecurringDepositProductRules recurringDepositProductRules;
/*
* Guaranteed to throw an exception no matter what the data integrity issue is.
@@ -303,12 +302,9 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
frequency = frequency == -1 ? 1 : frequency;
account.generateSchedule(frequencyType, frequency, calendar);
final boolean isPreMatureClosure = false;
// The simple/compounding flag must be on the account before its maturity is projected.
this.recurringDepositProductRules.applyInterestCalculationType(account);
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
account.validateApplicableInterestRate();
this.recurringDepositProductRules.validateOnSubmit(account, command);
savingAccountRepository.save(account);
// Save nominees if provided in payload (same transaction)
@@ -555,15 +551,12 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl
frequency = frequency == -1 ? 1 : frequency;
account.generateSchedule(frequencyType, frequency, calendar);
final boolean isPreMatureClosure = false;
// An application is not yet a contract, so an edit (possibly to a different product) re-reads it.
this.recurringDepositProductRules.applyInterestCalculationType(account);
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
account.validateApplicableInterestRate();
this.savingAccountRepository.save(account);
}
this.recurringDepositProductRules.validateOnModify(account, command);
// update calendar details
if (!account.isCalendarInherited()) {
@@ -499,10 +499,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.balance_frequency as balanceFrequency, ");
sqlBuilder.append("sp.account_closed_zero_balance_in_day_closing as accountClosedZeroBalanceInDayClosing, ");
sqlBuilder.append("sp.balance_swap_option as balanceSwapOption, sp.msme as msme, ");
sqlBuilder.append("sp.lien_percentage as lienPercentage, ");
sqlBuilder.append("sp.missed_installment_grace_days as missedInstallmentGraceDays, ");
sqlBuilder.append("sp.missed_installment_penalty_type as missedInstallmentPenaltyType, ");
sqlBuilder.append("sp.missed_installment_penalty_amount as missedInstallmentPenaltyAmount, ");
sqlBuilder.append("sp.auto_debit_facility as autoDebitFacility, sp.loan_facility as loanFacility, ");
sqlBuilder.append("sp.premature_withdrawal_option as prematureWithdrawalOption, sp.tenure_flexibility as tenureFlexibility, ");
sqlBuilder.append("sp.lien_marked as lienMarked, sp.maturity_instructions as maturityInstructions, ");
@@ -627,10 +623,6 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
result.setPrematureWithdrawalOption(prematureWithdrawalOption);
result.setTenureFlexibility(tenureFlexibility);
result.setLienMarked(lienMarked);
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
result.setMissedInstallmentGraceDays(JdbcSupport.getInteger(rs, "missedInstallmentGraceDays"));
result.setMissedInstallmentPenaltyType(JdbcSupport.getInteger(rs, "missedInstallmentPenaltyType"));
result.setMissedInstallmentPenaltyAmount(rs.getBigDecimal("missedInstallmentPenaltyAmount"));
result.setMaturityInstructions(maturityInstructions);
result.setCreditMaturityProceeds(creditMaturityProceeds);
result.setPenaltyGracePeriodMissedInstallments(penaltyGracePeriodMissedInstallments);
@@ -29,47 +29,19 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService;
import org.apache.fineract.infrastructure.core.api.JsonQuery;
import org.apache.fineract.infrastructure.core.domain.LocalDateInterval;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.organisation.monetary.domain.MonetaryCurrency;
import org.apache.fineract.organisation.monetary.domain.Money;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationDaysInYearType;
import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsHelper;
import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod;
import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@Service
@RequiredArgsConstructor
public class RecurringDepositAccountInterestCalculationServiceImpl implements RecurringDepositAccountInterestCalculationService {
private static final String PRODUCT_ID_PARAM = "productId";
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
private final SavingsHelper savingsHelper;
private final ConfigurationDomainService configurationDomainService;
private final RecurringDepositProductRules recurringDepositProductRules;
/** The interest settings of an RD product, as an account opened under it will be calculated with. */
private record ProductInterestTerms(SavingsCompoundingInterestPeriodType compounding, SavingsPostingInterestPeriodType posting,
SavingsInterestCalculationType calculationType, SavingsInterestCalculationDaysInYearType daysInYear, boolean simpleInterest,
BigDecimal minBalanceForInterest, MonetaryCurrency currency) {}
@Override
public HashMap<String, Object> calculateInterest(JsonQuery query) {
@@ -81,120 +53,27 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
element);
LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element);
if (submittedOnDate == null) {
submittedOnDate = DateUtils.getBusinessLocalDate();
submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
}
final Long productId = this.fromApiJsonHelper.extractLongNamed(PRODUCT_ID_PARAM, element);
final ProductInterestTerms terms = productId == null ? null : productInterestTerms(productId);
final HashMap<String, Object> result = new HashMap<>();
BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths, submittedOnDate);
BigDecimal totalPrincipal = depositAmount.multiply(BigDecimal.valueOf(tenureInMonths));
BigDecimal maturityAmount;
if (terms != null) {
// The product's own settings, through the same engine an opened account is calculated with - so the figure
// shown before opening is the maturity amount the account will carry.
final List<Map<String, Object>> postings = new ArrayList<>();
final BigDecimal totalInterest = projectInterest(terms, depositAmount, annualInterestRate, tenureInMonths.intValue(),
submittedOnDate, postings);
maturityAmount = totalPrincipal.add(totalInterest);
result.put("interestCalculation", terms.simpleInterest() ? "Simple" : "Compounding");
result.put("compoundingPeriod", terms.compounding().name());
result.put("postingPeriod", terms.posting().name());
result.put("postingSchedule", postings);
result.put("daysInYear", terms.daysInYear().getValue());
} else {
maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths, submittedOnDate);
result.put("daysInYear", 365);
}
BigDecimal totalInterest = maturityAmount.subtract(totalPrincipal);
String warning = terms != null ? "Calculated with the product's interest settings, assuming every installment is paid on its due date"
: "This is an approximate calculated amount - it may vary slightly when the account is created";
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
HashMap<String, Object> result = new HashMap<>();
result.put("maturityAmount", maturityAmount);
result.put("totalPrincipal", totalPrincipal);
result.put("totalInterest", totalInterest);
result.put("daysInYear", 365); // Standard for this product as per image
result.put("warning", warning);
return result;
}
private ProductInterestTerms productInterestTerms(final Long productId) {
try {
return this.jdbcTemplate.queryForObject("""
SELECT interest_compounding_period_enum, interest_posting_period_enum, interest_calculation_type_enum,
interest_calculation_days_in_year_type_enum, interest_calculate_selector,
min_balance_for_interest_calculation, currency_code, currency_digits, currency_multiplesof
FROM m_savings_product WHERE id = ? AND deposit_type_enum = 300
""", (rs, rowNum) -> {
final Integer selector = (Integer) rs.getObject("interest_calculate_selector");
final Integer multiplesOf = (Integer) rs.getObject("currency_multiplesof");
return new ProductInterestTerms(SavingsCompoundingInterestPeriodType.fromInt(rs.getInt("interest_compounding_period_enum")),
SavingsPostingInterestPeriodType.fromInt(rs.getInt("interest_posting_period_enum")),
SavingsInterestCalculationType.fromInt(rs.getInt("interest_calculation_type_enum")),
SavingsInterestCalculationDaysInYearType.fromInt(rs.getInt("interest_calculation_days_in_year_type_enum")),
this.recurringDepositProductRules.isSimpleInterestOption(selector), rs.getBigDecimal("min_balance_for_interest_calculation"),
new MonetaryCurrency(rs.getString("currency_code"), rs.getInt("currency_digits"), multiplesOf));
}, productId);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
/**
* Interest on a deposit of {@code installment} every month from {@code startDate} for {@code months} months, worked
* out by the account engine itself (posting periods, compounding periods, daily balances, days in year, simple or
* compounding) up to the day before maturity - the same calculation that gives an opened account its maturity
* amount. Each posting period's credit is added to {@code postings}.
*/
private BigDecimal projectInterest(final ProductInterestTerms terms, final BigDecimal installment, final BigDecimal annualRate,
final int months, final LocalDate startDate, final List<Map<String, Object>> postings) {
final LocalDate termEnd = startDate.plusMonths(months).minusDays(1);
final MonetaryCurrency currency = terms.currency();
final List<SavingsAccountTransactionDetailsForPostingPeriod> deposits = new ArrayList<>();
for (int k = 0; k < months; k++) {
final LocalDate dueDate = startDate.plusMonths(k);
final LocalDate balanceEnd = k == months - 1 ? termEnd : startDate.plusMonths(k + 1L).minusDays(1);
final BigDecimal runningBalance = installment.multiply(BigDecimal.valueOf(k + 1L));
final int days = LocalDateInterval.create(dueDate, balanceEnd).daysInPeriodInclusiveOfEndDate();
deposits.add(new SavingsAccountTransactionDetailsForPostingPeriod(null, dueDate, balanceEnd, runningBalance, installment, currency,
days, true, false, false, false, false));
}
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final boolean postingAtPeriodEnd = this.configurationDomainService.isSavingsInterestPostingAtCurrentPeriodEnd();
final List<LocalDateInterval> intervals = this.savingsHelper.determineInterestPostingPeriods(startDate, termEnd, terms.posting(),
financialYearBeginningMonth, new ArrayList<>());
final BigDecimal rateAsFraction = annualRate.divide(BigDecimal.valueOf(100L), MathContext.DECIMAL64);
final Money minBalance = Money.of(currency, terms.minBalanceForInterest() == null ? BigDecimal.ZERO : terms.minBalanceForInterest());
final List<PostingPeriod> periods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
for (final LocalDateInterval interval : intervals) {
final PostingPeriod period = PostingPeriod.createFrom(interval, periodStartingBalance, deposits, currency, terms.compounding(),
terms.calculationType(), rateAsFraction, terms.daysInYear().getValue(), termEnd, new ArrayList<>(), false, minBalance,
postingAtPeriodEnd, false, financialYearBeginningMonth);
periodStartingBalance = period.closingBalance();
periods.add(period);
}
this.savingsHelper.calculateInterestForAllPostingPeriods(currency, periods, null, false, terms.simpleInterest());
BigDecimal total = BigDecimal.ZERO;
for (final PostingPeriod period : periods) {
final BigDecimal interest = period.getInterestEarned().getAmount();
total = total.add(interest);
final Map<String, Object> posting = new LinkedHashMap<>();
// The last period is credited on the maturity date itself.
final LocalDate periodEnd = period.getPeriodInterval().endDate();
final LocalDate postedOn = periodEnd.isBefore(termEnd) ? periodEnd : termEnd.plusDays(1);
posting.put("date", postedOn.toString());
posting.put("interest", interest);
postings.add(posting);
}
return total;
}
public BigDecimal calculateInterestInternal(BigDecimal installmentAmount, BigDecimal annualInterestRate, Long tenureInMonths,
Long interestCompoundingPeriodInMonths, LocalDate submittedOnDate) {
MathContext mc = MoneyHelper.getMathContext();
@@ -210,31 +89,39 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
for (int m = 1; m <= totalMonths; m++) {
// Add monthly installment at the start of the month
currentBalance = currentBalance.add(installmentAmount);
// Get actual days in the current month
int daysInMonth = date.lengthOfMonth();
// Calculate interest for the month using daily compounding
double multiplier = Math.pow(1.0 + dailyRate, daysInMonth);
currentBalance = currentBalance.multiply(BigDecimal.valueOf(multiplier), mc);
// Move to next month
date = date.plusMonths(1);
}
maturityAmount = currentBalance;
} else {
// Periodic Compounding (Monthly, Quarterly, etc.)
BigDecimal monthlyRate = annualInterestRate.divide(BigDecimal.valueOf(1200), mc);
BigDecimal i = monthlyRate;
int n = totalMonths;
// Standard RD formula for monthly compounding:
// M = P * ((1+i)^n - 1) / i * (1+i)
// For other periods, we can approximate or use a loop.
// Using loop for consistency with various compounding periods.
BigDecimal compoundingFrequencyMonths = BigDecimal.valueOf(interestCompoundingPeriodInMonths);
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc).multiply(compoundingFrequencyMonths)
.divide(BigDecimal.valueOf(12), mc);
BigDecimal ratePerCompounding = annualInterestRate.divide(BigDecimal.valueOf(100), mc)
.multiply(compoundingFrequencyMonths).divide(BigDecimal.valueOf(12), mc);
for (int m = 1; m <= totalMonths; m++) {
BigDecimal monthsRemaining = BigDecimal.valueOf(totalMonths - m + 1);
BigDecimal numberOfCompoundings = monthsRemaining.divide(compoundingFrequencyMonths, mc);
BigDecimal installmentMaturity = installmentAmount
.multiply(BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
BigDecimal installmentMaturity = installmentAmount.multiply(
BigDecimal.ONE.add(ratePerCompounding).pow(numberOfCompoundings.intValue(), mc), mc);
// Add remaining partial compounding period interest (simple interest for the rest)
BigDecimal remainingMonths = monthsRemaining.remainder(compoundingFrequencyMonths);
if (remainingMonths.compareTo(BigDecimal.ZERO) > 0) {
@@ -242,11 +129,11 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
.divide(BigDecimal.valueOf(1200), mc);
installmentMaturity = installmentMaturity.add(partialInterest);
}
maturityAmount = maturityAmount.add(installmentMaturity);
}
}
return maturityAmount.setScale(2, RoundingMode.HALF_UP);
}
}
@@ -1,157 +0,0 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
import org.apache.fineract.portfolio.account.service.AccountTransfersWritePlatformService;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* RD Auto Debit: on each due date, collects the installment of every recurring deposit opened with Auto Debit from
* the Savings account linked to it, as an ordinary account transfer (debit the savings account, credit the deposit),
* so it is booked, journalled and shown on both statements exactly like a transfer the customer made themselves.
*
* <ul>
* <li>Only installments due on or before the business date and not yet fully paid are collected - oldest first, one
* transfer each, each in its own transaction.</li>
* <li>An installment the savings account cannot cover is skipped and stays overdue; nothing is partly collected, and
* the rest of that deposit's installments wait for the next run so they are never paid out of order. Every other
* deposit is unaffected.</li>
* <li>Retrying is the product's choice ("Allow Debit Txn after due date"): with it on, anything still unpaid is
* collected on a later run; with it off, an installment is debited only on its due date and a missed one stays
* overdue for the customer to pay (and for the missed-installment penalty, if the product has one).</li>
* </ul>
*/
@Slf4j
@Service
public class RecurringDepositAutoDebitService {
/**
* Unpaid installments, plus per deposit what has been due up to today and what has actually been paid in. The two
* totals matter because a deposit opened without "adjust advance payments" never allocates money paid before a
* due date to that installment - the schedule shows it unpaid though the customer already paid at the counter.
*/
private static final String DUE_INSTALLMENTS_SQL = """
SELECT rd.id AS rd_id, rd.linked_account_id AS savings_id, s.duedate AS due_date,
s.deposit_amount - COALESCE(s.deposit_amount_completed_derived, 0) AS outstanding,
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
WHERE s2.savings_account_id = rd.id AND s2.duedate <= ?) AS total_due,
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
FROM m_savings_account rd
JOIN m_savings_product sp ON sp.id = rd.product_id
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
JOIN m_savings_account sav ON sav.id = rd.linked_account_id
WHERE rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND rd.auto_debit_facility = true
AND UPPER(rd.linked_account_type) = 'SAVINGS'
AND sav.deposit_type_enum = 100
AND sav.status_enum = 300
AND COALESCE(s.completed_derived, false) = false
AND s.duedate <= ?
AND (COALESCE(sp.allow_debit_txn_after_due_date, false) = true OR s.duedate = ?)
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
ORDER BY rd.id, s.duedate
""";
private final JdbcTemplate jdbcTemplate;
private final AccountTransfersWritePlatformService accountTransfersWritePlatformService;
private final TransactionTemplate perInstallmentTransaction;
public RecurringDepositAutoDebitService(final JdbcTemplate jdbcTemplate,
final AccountTransfersWritePlatformService accountTransfersWritePlatformService,
final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.accountTransfersWritePlatformService = accountTransfersWritePlatformService;
this.perInstallmentTransaction = new TransactionTemplate(transactionManager);
this.perInstallmentTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/** @return how many installments were collected */
public int collectDueInstallments() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final List<DueInstallment> dueInstallments = this.jdbcTemplate.query(DUE_INSTALLMENTS_SQL,
(rs, rowNum) -> new DueInstallment(rs.getLong("rd_id"), rs.getLong("savings_id"), rs.getObject("due_date", LocalDate.class),
rs.getBigDecimal("outstanding"), rs.getBigDecimal("total_due").subtract(rs.getBigDecimal("total_paid"))),
businessDate, businessDate, businessDate);
int collected = 0;
Long blockedDeposit = null;
Long currentDeposit = null;
BigDecimal stillShort = BigDecimal.ZERO;
for (final DueInstallment installment : dueInstallments) {
if (installment.recurringDepositId().equals(blockedDeposit)) {
continue;
}
if (!installment.recurringDepositId().equals(currentDeposit)) {
currentDeposit = installment.recurringDepositId();
// What the customer still owes up to today - zero when they already paid it at the counter.
stillShort = installment.shortfall().max(BigDecimal.ZERO);
}
final BigDecimal amount = installment.outstanding().min(stillShort);
if (amount.signum() <= 0) {
continue;
}
try {
this.perInstallmentTransaction.executeWithoutResult(status -> collect(installment, amount, businessDate));
stillShort = stillShort.subtract(amount);
collected++;
} catch (final RuntimeException e) {
// Most often the linked savings account is short of funds. Leave this deposit's remaining
// installments for the next run rather than paying a later one ahead of the one that failed.
blockedDeposit = installment.recurringDepositId();
log.warn("RD Auto Debit could not collect the installment due {} on recurring deposit {} from savings account {}: {}",
installment.dueDate(), installment.recurringDepositId(), installment.savingsAccountId(), e.getMessage());
}
}
log.info("RD Auto Debit collected {} of {} due installment(s) on {}", collected, dueInstallments.size(), businessDate);
return collected;
}
private void collect(final DueInstallment installment, final BigDecimal amount, final LocalDate businessDate) {
final SavingsAccount fromSavingsAccount = null;
final SavingsAccount toSavingsAccount = null;
final boolean isRegularTransaction = true;
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO transfer = new AccountTransferDTO(businessDate, amount, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, installment.savingsAccountId(), installment.recurringDepositId(),
"RD Auto Debit - installment due " + installment.dueDate(), null, null, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, fromSavingsAccount,
isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(transfer);
}
/** shortfall = everything due on the deposit up to today, less everything actually paid into it. */
private record DueInstallment(Long recurringDepositId, Long savingsAccountId, LocalDate dueDate, BigDecimal outstanding,
BigDecimal shortfall) {}
}
@@ -1,176 +0,0 @@
/**
* 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.savings.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.List;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* Lien on a recurring deposit pledged as security for a loan.
*
* <p>
* The lien itself is the existing guarantor mechanism: adding the deposit as a guarantor account on a loan records a
* funding line, and the guarantor module places the amount on hold when the loan is approved/disbursed, releases it as
* the loan is repaid, and releases the rest when the loan closes. What this class adds is what that mechanism never
* did for a recurring deposit:
* <ul>
* <li>only an RD whose product has <b>Lien Marked</b> on may be pledged, and never for more than the product's lien
* percentage of its balance (the whole balance when none is set), less what it already secures;</li>
* <li>a pledged RD cannot be closed - premature or on maturity - while any loan it secures is still pending or
* running. Before this, the hold only surfaced as an "insufficient balance" error deep inside the closure, and a pledge
* on a loan not yet approved (no hold placed yet) did not stop the deposit being paid out at all;</li>
* <li>which loans hold how much, so the RD screens can show it.</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class RecurringDepositLienService {
/**
* Pledges still in force: the funding line is active and the loan is submitted (100), approved (200) or active
* (300). A loan that is closed, rejected or withdrawn no longer binds the deposit.
*/
private static final String ACTIVE_LIENS_SQL = """
SELECT l.id AS loan_id, l.account_no AS loan_account_no, l.loan_status_id AS loan_status,
COALESCE(gfd.amount_remaining_derived, gfd.amount) AS lien_amount
FROM m_guarantor_funding_details gfd
JOIN m_portfolio_account_associations paa ON paa.id = gfd.account_associations_id
JOIN m_loan l ON l.id = paa.loan_account_id
WHERE paa.linked_savings_account_id = ?
AND gfd.status_enum = 100
AND l.loan_status_id IN (100, 200, 300)
AND COALESCE(gfd.amount_remaining_derived, gfd.amount) > 0
ORDER BY l.id
""";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private final JdbcTemplate jdbcTemplate;
public record Lien(Long loanId, String loanAccountNo, Integer loanStatus, BigDecimal amount) {}
/**
* The loan limit a deposit carries today: whether its product allows a lien at all, the percentage that applies
* (the deposit's own, else its product's, else 100), and what that comes to on the current balance.
*/
public record LienLimit(boolean lienAllowed, BigDecimal accountPercentage, BigDecimal productPercentage,
BigDecimal effectivePercentage, BigDecimal balance, BigDecimal maxLoanAmount) {}
private static final String LIEN_LIMIT_SQL = """
SELECT sp.lien_marked AS lien_marked, sa.lien_percentage AS account_pct, sp.lien_percentage AS product_pct,
COALESCE(sa.account_balance_derived, 0) AS balance
FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""";
public LienLimit lienLimit(final Long depositAccountId) {
final List<LienLimit> rows = this.jdbcTemplate.query(LIEN_LIMIT_SQL, (rs, rowNum) -> {
final boolean lienAllowed = rs.getBoolean("lien_marked");
final BigDecimal accountPct = rs.getBigDecimal("account_pct");
final BigDecimal productPct = rs.getBigDecimal("product_pct");
final BigDecimal balance = rs.getBigDecimal("balance");
final BigDecimal effective = accountPct != null ? accountPct : productPct != null ? productPct : ONE_HUNDRED;
final BigDecimal maxLoan = lienAllowed ? balance.multiply(effective).divide(ONE_HUNDRED, 2, RoundingMode.DOWN)
: BigDecimal.ZERO;
return new LienLimit(lienAllowed, accountPct, productPct, effective, balance, maxLoan);
}, depositAccountId);
return rows.isEmpty() ? null : rows.get(0);
}
public List<Lien> activeLiens(final Long depositAccountId) {
return this.jdbcTemplate.query(ACTIVE_LIENS_SQL, (rs, rowNum) -> new Lien(rs.getLong("loan_id"), rs.getString("loan_account_no"),
rs.getInt("loan_status"), rs.getBigDecimal("lien_amount")), depositAccountId);
}
public BigDecimal totalLien(final Long depositAccountId) {
return activeLiens(depositAccountId).stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
}
/** Refuses to pay out a recurring deposit that still secures a loan. */
public void assertNoActiveLien(final Long depositAccountId, final String action) {
final List<Lien> liens = activeLiens(depositAccountId);
if (liens.isEmpty()) {
return;
}
final BigDecimal total = liens.stream().map(Lien::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
final String loans = liens.stream().map(lien -> lien.loanAccountNo() + " (" + money(lien.amount()) + ")")
.collect(Collectors.joining(", "));
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.marked",
"This recurring deposit has a lien of " + money(total) + " for loan(s) " + loans + ". It cannot be " + action
+ " until the loan is repaid or the guarantee is released.",
depositAccountId, total);
}
/**
* Rules for pledging an account as loan security. Only recurring deposits are checked here; other account types
* keep the guarantor module's existing behaviour.
*
* @param excludingLoanId
* the loan being edited, whose own earlier pledge must not count against the new amount
*/
public void validatePledge(final SavingsAccount account, final BigDecimal amount, final Long excludingLoanId) {
if (account == null || !account.depositAccountType().isRecurringDeposit()) {
return;
}
if (account.savingsProduct() == null || !account.savingsProduct().isLienMarked()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.not.allowed",
"Recurring deposit " + account.getAccountNumber() + " cannot be pledged: its product does not have Lien Marked enabled.",
account.getId());
}
if (!account.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.account.not.active",
"Recurring deposit " + account.getAccountNumber() + " is not active, so it cannot be pledged.", account.getId());
}
final BigDecimal alreadyPledged = activeLiens(account.getId()).stream()
.filter(lien -> excludingLoanId == null || !excludingLoanId.equals(lien.loanId())).map(Lien::amount)
.reduce(BigDecimal.ZERO, BigDecimal::add);
// The lien percentage caps the total lien at that share of the balance as it stands now, when the loan is
// taken (90% of 1,00,000 = 90,000 across every loan the deposit secures). The deposit's own percentage, set
// when it was opened, wins; else the product's; neither means the whole balance.
final BigDecimal lienPercentage = account.getLienPercentage() != null ? account.getLienPercentage()
: account.savingsProduct().getLienPercentage();
final BigDecimal balance = account.getAccountBalance();
final BigDecimal pledgeable = lienPercentage == null ? balance
: balance.multiply(lienPercentage).divide(ONE_HUNDRED, 2, RoundingMode.DOWN);
final BigDecimal available = pledgeable.subtract(alreadyPledged);
if (amount != null && amount.compareTo(available) > 0) {
final String limitText = lienPercentage == null ? "balance " + balance.toPlainString()
: lienPercentage.stripTrailingZeros().toPlainString() + "% of balance " + balance.toPlainString() + " = "
+ pledgeable.toPlainString();
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.exceeds.limit",
"Recurring deposit " + account.getAccountNumber() + " can secure at most " + available.max(BigDecimal.ZERO).toPlainString()
+ " (" + limitText + ", less " + money(alreadyPledged) + " already pledged); "
+ money(amount) + " was requested.",
account.getId(), amount, available);
}
}
/** Rupees to the paisa for messages - "10000.00", not the stored "10000.000000". */
private static String money(final BigDecimal amount) {
return amount == null ? "0.00" : amount.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}
@@ -1,242 +0,0 @@
/**
* 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.savings.service;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.util.List;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.TransactionDefinition;
import org.springframework.transaction.support.TransactionTemplate;
/**
* Missed-installment penalty for recurring deposits (product setting "Penalty / Grace Period for Missed
* Installments").
*
* <p>
* Every day, each installment still unpaid when its due date plus the product's grace days has passed is charged
* the product's penalty once - a flat amount, or a percentage of the installment. The penalty is the system charge
* "RD Missed Installment Penalty" added to the deposit and paid out of its balance, so it shows on the statement as
* a charge and is booked to the product's Income From Penalties account like any other penalty.
*
* <ul>
* <li>One penalty per installment, ever: m_rd_installment_penalty records which installments have been charged,
* and an installment paid late afterwards is not charged again.</li>
* <li>Paying an installment within the grace days costs nothing.</li>
* <li>When the deposit does not yet hold enough to pay the penalty (the first installment missed, say), the charge
* stays on the account as due and every later run tries again.</li>
* </ul>
*/
@Slf4j
@Service
public class RecurringDepositMissedInstallmentPenaltyService {
public static final String PENALTY_CHARGE_NAME = "RD Missed Installment Penalty";
public static final int PENALTY_TYPE_FLAT = 1;
public static final int PENALTY_TYPE_PERCENT = 2;
private static final String DATE_FORMAT = "yyyy-MM-dd";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
/** Unpaid installments of active RDs whose product has the penalty on and that have not been penalised yet. */
private static final String CANDIDATES_SQL = """
SELECT s.id AS installment_id, rd.id AS rd_id, s.duedate AS due_date, s.deposit_amount AS installment_amount,
COALESCE(sp.missed_installment_grace_days, 0) AS grace_days,
COALESCE(sp.missed_installment_penalty_type, 1) AS penalty_type,
sp.missed_installment_penalty_amount AS penalty_amount,
(SELECT COALESCE(SUM(s2.deposit_amount), 0) FROM m_mandatory_savings_schedule s2
WHERE s2.savings_account_id = rd.id AND s2.duedate <= s.duedate) AS due_through_installment,
(SELECT COALESCE(SUM(t.amount), 0) FROM m_savings_account_transaction t
WHERE t.savings_account_id = rd.id AND t.transaction_type_enum = 1 AND t.is_reversed = false) AS total_paid
FROM m_savings_account rd
JOIN m_savings_product sp ON sp.id = rd.product_id
JOIN m_mandatory_savings_schedule s ON s.savings_account_id = rd.id
WHERE rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND sp.penalty_grace_period_missed_installments = true
AND COALESCE(sp.missed_installment_penalty_amount, 0) > 0
AND COALESCE(s.completed_derived, false) = false
AND s.deposit_amount > COALESCE(s.deposit_amount_completed_derived, 0)
AND s.duedate < ?
AND NOT EXISTS (SELECT 1 FROM m_rd_installment_penalty p WHERE p.installment_id = s.id)
ORDER BY rd.id, s.duedate
""";
/**
* Penalty charges still unpaid on active recurring deposits. Found by the penalty charge definition rather than
* through the tracking table, so nothing depends on the charge id being known at the moment it was added.
*/
private static final String UNPAID_SQL = """
SELECT sac.savings_account_id AS rd_id, sac.id AS charge_id, sac.amount_outstanding_derived AS outstanding
FROM m_savings_account_charge sac
JOIN m_savings_account rd ON rd.id = sac.savings_account_id
WHERE sac.charge_id = ?
AND rd.deposit_type_enum = 300
AND rd.status_enum = 300
AND sac.is_active = true
AND COALESCE(sac.is_paid_derived, false) = false
AND COALESCE(sac.waived, false) = false
AND sac.amount_outstanding_derived > 0
ORDER BY sac.id
""";
private final JdbcTemplate jdbcTemplate;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final FromJsonHelper fromJsonHelper;
private final TransactionTemplate ownTransaction;
public RecurringDepositMissedInstallmentPenaltyService(final JdbcTemplate jdbcTemplate,
final DepositAccountWritePlatformService depositAccountWritePlatformService, final FromJsonHelper fromJsonHelper,
final PlatformTransactionManager transactionManager) {
this.jdbcTemplate = jdbcTemplate;
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
this.fromJsonHelper = fromJsonHelper;
this.ownTransaction = new TransactionTemplate(transactionManager);
this.ownTransaction.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
}
/**
* paidInFull: everything due up to and including this installment has actually been paid into the deposit - e.g.
* in advance at the counter, which a deposit opened without "adjust advance payments" never allocates to the
* installment itself. Such an installment only looks unpaid and must not be penalised.
*/
private record Candidate(Long installmentId, Long rdId, LocalDate dueDate, BigDecimal installmentAmount, int graceDays, int penaltyType,
BigDecimal penaltyAmount, boolean paidInFull) {}
private record Unpaid(Long rdId, Long chargeId, BigDecimal outstanding) {}
/** @return how many installments were newly penalised */
public int applyPenalties() {
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final Long penaltyChargeId = penaltyChargeId();
if (penaltyChargeId == null) {
log.warn("Charge '{}' not found - missed-installment penalties cannot be applied", PENALTY_CHARGE_NAME);
return 0;
}
final List<Candidate> candidates = this.jdbcTemplate.query(CANDIDATES_SQL,
(rs, rowNum) -> new Candidate(rs.getLong("installment_id"), rs.getLong("rd_id"), rs.getObject("due_date", LocalDate.class),
rs.getBigDecimal("installment_amount"), rs.getInt("grace_days"), rs.getInt("penalty_type"),
rs.getBigDecimal("penalty_amount"),
rs.getBigDecimal("total_paid").compareTo(rs.getBigDecimal("due_through_installment")) >= 0),
businessDate);
int penalised = 0;
for (final Candidate candidate : candidates) {
// Grace: due on the 10th with 5 grace days is penalised from the 16th.
if (!candidate.dueDate().plusDays(candidate.graceDays()).isBefore(businessDate) || candidate.paidInFull()) {
continue;
}
final BigDecimal penalty = penaltyFor(candidate);
if (penalty.signum() <= 0) {
continue;
}
try {
this.ownTransaction.executeWithoutResult(status -> charge(candidate, penalty, penaltyChargeId, businessDate));
penalised++;
} catch (final RuntimeException e) {
log.warn("Could not charge the missed-installment penalty on RD {} (installment due {}): {}", candidate.rdId(),
candidate.dueDate(), e.getMessage());
}
}
// Collect everything now due - tonight's penalties and any an earlier run could not cover.
collectUnpaid(penaltyChargeId, businessDate);
log.info("RD missed-installment penalty: {} installment(s) penalised on {}", penalised, businessDate);
return penalised;
}
/** Flat amount, or the percentage of this installment, rounded to the paisa. */
static BigDecimal penaltyFor(final Candidate candidate) {
if (candidate.penaltyType() == PENALTY_TYPE_PERCENT) {
return candidate.installmentAmount().multiply(candidate.penaltyAmount()).divide(ONE_HUNDRED, 2, RoundingMode.HALF_UP);
}
return candidate.penaltyAmount().setScale(2, RoundingMode.HALF_UP);
}
/** Records the installment as penalised and adds the penalty charge to the deposit, as one unit. */
private void charge(final Candidate candidate, final BigDecimal penalty, final Long penaltyChargeId, final LocalDate businessDate) {
this.jdbcTemplate.update("""
INSERT INTO m_rd_installment_penalty (installment_id, savings_account_id, penalty_amount, applied_on_date)
VALUES (?, ?, ?, ?)
""", candidate.installmentId(), candidate.rdId(), penalty, businessDate);
final JsonObject body = new JsonObject();
body.addProperty("chargeId", penaltyChargeId);
body.addProperty("amount", penalty);
body.addProperty("dueDate", businessDate.toString());
body.addProperty("dateFormat", DATE_FORMAT);
body.addProperty("locale", "en");
final CommandProcessingResult result = this.depositAccountWritePlatformService.addSavingsAccountCharge(command(body, candidate.rdId()),
DepositAccountType.RECURRING_DEPOSIT);
// The charge's id is only known here if the add happened to flush; keep the link when it is, for reference.
if (result.getResourceId() != null) {
this.jdbcTemplate.update("UPDATE m_rd_installment_penalty SET savings_account_charge_id = ? WHERE installment_id = ?",
result.getResourceId(), candidate.installmentId());
}
}
private void collectUnpaid(final Long penaltyChargeId, final LocalDate businessDate) {
final List<Unpaid> unpaid = this.jdbcTemplate.query(UNPAID_SQL,
(rs, rowNum) -> new Unpaid(rs.getLong("rd_id"), rs.getLong("charge_id"), rs.getBigDecimal("outstanding")), penaltyChargeId);
for (final Unpaid charge : unpaid) {
tryPay(charge.rdId(), charge.chargeId(), charge.outstanding(), businessDate);
}
}
/** Pays the penalty from the deposit's balance; if it cannot be covered yet the charge simply stays due. */
private void tryPay(final Long rdId, final Long chargeId, final BigDecimal amount, final LocalDate businessDate) {
final JsonObject body = new JsonObject();
body.addProperty("amount", amount);
body.addProperty("dueDate", businessDate.toString());
body.addProperty("dateFormat", DATE_FORMAT);
body.addProperty("locale", "en");
try {
this.ownTransaction.executeWithoutResult(status -> this.depositAccountWritePlatformService.payCharge(rdId, chargeId,
command(body, rdId), DepositAccountType.RECURRING_DEPOSIT));
} catch (final RuntimeException e) {
log.info("Missed-installment penalty {} on RD {} left due (not collected yet): {}", chargeId, rdId, e.getMessage());
}
}
private Long penaltyChargeId() {
try {
return this.jdbcTemplate.queryForObject(
"SELECT id FROM m_charge WHERE name = ? AND is_deleted = false AND is_active = true ORDER BY id LIMIT 1", Long.class,
PENALTY_CHARGE_NAME);
} catch (final EmptyResultDataAccessException e) {
return null;
}
}
/** A command carrying the deposit's id the way the charge endpoints receive it from the API. */
private JsonCommand command(final JsonObject body, final Long rdId) {
return new JsonCommand(null, body.toString(), body, this.fromJsonHelper, null, rdId, null, null, null, null, rdId, null, null,
null, null, null, null, null);
}
}
@@ -1,301 +0,0 @@
/**
* 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.savings.service;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.codes.domain.CodeValueRepository;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.savings.domain.DepositProductAmountDetails;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositProduct;
import org.apache.fineract.portfolio.savings.domain.SavingsProduct;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* The recurring-deposit product settings that only mean something when an account is opened against them. Each one
* used to be stored on the product and then ignored; they are enforced here, on the server, so a client that skips
* the screen's own checks cannot open an account the product does not allow.
*
* <ul>
* <li>Effective Date - the first day the product can be used. No account may be opened before it.</li>
* <li>Effective To - the last day the product is on sale. No new account may be opened after it; accounts already
* open run to maturity as normal.</li>
* <li>End Date - the day the product is wound up. No account may be opened that would still be running after it, so
* every account opened under the product has matured by then.</li>
* <li>Deposit Amount Min / Max - bounds on the installment the customer commits to each period.</li>
* <li>Nomination Facility - when on, at least one nominee is required; when off, nominees cannot be recorded.</li>
* <li>Auto Debit Facility - only offered when the product allows it, and only with a linked Savings account to debit,
* since that is where the installment is collected from.</li>
* <li>Interest Calculation Type - copied onto the account as its simple/compounding flag (see
* {@link #isSimpleInterestOption}).</li>
* </ul>
*/
@Component
@RequiredArgsConstructor
public class RecurringDepositProductRules {
/** Label of the InterestCalculationTypeOption code value that switches a recurring deposit to simple interest. */
public static final String SIMPLE_INTEREST_OPTION_LABEL = "Simple";
private static final String LINKED_ACCOUNT_TYPE_SAVINGS = "SAVINGS";
private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100);
private final CodeValueRepository codeValueRepository;
private final JdbcTemplate jdbcTemplate;
/**
* Fixes the account's simple/compounding flag from its product. Must run before the maturity amount is projected,
* which is calculated with it.
*/
public void applyInterestCalculationType(final RecurringDepositAccount account) {
account.setSimpleInterest(isSimpleInterestOption(account.savingsProduct().getInterestCalculateSelector()));
}
/** Rules for a brand-new application. Runs after the maturity date is known, which End Date is checked against. */
public void validateOnSubmit(final RecurringDepositAccount account, final JsonCommand command) {
final SavingsProduct product = account.savingsProduct();
applyProductPreClosurePenalty(account, product);
applyLienPercentage(account, product, command, true);
validateOpeningWindow(account, product);
validateInstallmentAmount(account, product);
validateNomination(product, command);
validateAutoDebit(account, product);
validateIntroducer(account, product);
}
/**
* Rules for an application edited before approval. Nominees are only checked when the edit sends them, since an
* edit that leaves them out keeps the ones already recorded.
*/
public void validateOnModify(final RecurringDepositAccount account, final JsonCommand command) {
final SavingsProduct product = account.savingsProduct();
applyProductPreClosurePenalty(account, product);
applyLienPercentage(account, product, command, false);
validateOpeningWindow(account, product);
validateInstallmentAmount(account, product);
if (hasNomineesProperty(command)) {
validateNomination(product, command);
}
validateAutoDebit(account, product);
validateIntroducer(account, product);
}
/**
* Whether a product's Interest Calculation Type is "Simple". The option is a tenant-editable code value, so it is
* matched by label rather than by a hard-coded id; anything else - no selection, "Compounding", an unknown id - is
* compounding, which is how every recurring deposit was calculated before the option was honoured.
*/
public boolean isSimpleInterestOption(final Integer interestCalculateSelector) {
if (interestCalculateSelector == null) {
return false;
}
return this.codeValueRepository.findById(interestCalculateSelector.longValue())
.map(codeValue -> SIMPLE_INTEREST_OPTION_LABEL.equalsIgnoreCase(StringUtils.trim(codeValue.getLabel()))).orElse(false);
}
/**
* How much of this deposit may be given as loan. Only a Lien Marked product allows a lien at all; the account's
* percentage may be lowered from the product's but never raised above it. When the application sends none, a new
* account takes the product's percentage and an edited one keeps what it had.
*/
private void applyLienPercentage(final RecurringDepositAccount account, final SavingsProduct product, final JsonCommand command,
final boolean isSubmit) {
if (!product.isLienMarked()) {
account.setLienPercentage(null);
return;
}
final BigDecimal productLimit = product.getLienPercentage() == null ? ONE_HUNDRED : product.getLienPercentage();
if (!command.parameterExists("lienPercentage") || command.bigDecimalValueOfParameterNamed("lienPercentage") == null) {
if (isSubmit) {
account.setLienPercentage(product.getLienPercentage());
}
return;
}
final BigDecimal requested = command.bigDecimalValueOfParameterNamed("lienPercentage");
if (requested.signum() <= 0 || requested.compareTo(productLimit) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.lien.percentage.out.of.range",
"Loan against this deposit must be more than 0% and at most " + productLimit.stripTrailingZeros().toPlainString()
+ "% (the limit of product '" + product.getName() + "'); " + requested.stripTrailingZeros().toPlainString() + "% was entered.",
requested, productLimit);
}
account.setLienPercentage(requested);
}
private void validateOpeningWindow(final RecurringDepositAccount account, final SavingsProduct product) {
final LocalDate openedOn = account.getSubmittedOnDate();
if (openedOn == null) {
return;
}
final LocalDate effectiveFrom = product.getEffectiveDate();
if (effectiveFrom != null && openedOn.isBefore(effectiveFrom)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.not.yet.effective",
"Product '" + product.getName() + "' can only be used from " + effectiveFrom + "; this application is dated "
+ openedOn + ".",
effectiveFrom, openedOn);
}
final LocalDate effectiveTo = product.getEffectiveTo();
if (effectiveTo != null && openedOn.isAfter(effectiveTo)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.product.closed.for.new.accounts",
"Product '" + product.getName() + "' stopped accepting new accounts after " + effectiveTo
+ "; this application is dated " + openedOn + ".",
effectiveTo, openedOn);
}
final LocalDate endDate = product.getEndDate();
final LocalDate maturityDate = account.maturityDate();
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
+ ". Choose a shorter tenure.",
maturityDate, endDate);
}
}
/**
* "Apply Penal Interest (less)" is the product's decision, not the account's: whatever the application sent, the
* deposit takes the product's penalty - on or off, the same percentage and the same period (Whole Term / Till
* Premature Withdrawal). A product without the penalty means no penal interest on premature closure.
*/
private void applyProductPreClosurePenalty(final RecurringDepositAccount account, final SavingsProduct product) {
if (product instanceof RecurringDepositProduct rdProduct && rdProduct.depositProductTermAndPreClosure() != null) {
account.applyProductPreClosurePenalty(rdProduct.depositProductTermAndPreClosure().depositPreClosureDetail());
}
}
/** A deposit whose tenure is extended must still mature on or before its product's End Date. */
public void validateMaturityWithinProductEndDate(final RecurringDepositAccount account) {
final SavingsProduct product = account.savingsProduct();
final LocalDate endDate = product.getEndDate();
final LocalDate maturityDate = account.maturityDate();
if (endDate != null && maturityDate != null && maturityDate.isAfter(endDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.maturity.after.product.end.date",
"This deposit would mature on " + maturityDate + ", after product '" + product.getName() + "' ends on " + endDate
+ ". Choose a shorter tenure.",
maturityDate, endDate);
}
}
private void validateInstallmentAmount(final RecurringDepositAccount account, final SavingsProduct product) {
if (!(product instanceof RecurringDepositProduct rdProduct) || rdProduct.depositProductTermAndPreClosure() == null) {
return;
}
final DepositProductAmountDetails limits = rdProduct.depositProductTermAndPreClosure().depositProductAmountDetails();
final BigDecimal installment = account.getRecurringDetail() == null ? null
: account.getRecurringDetail().mandatoryRecommendedDepositAmount();
if (limits == null || installment == null) {
return;
}
final BigDecimal min = limits.getMinDepositAmount();
if (min != null && min.signum() > 0 && installment.compareTo(min) < 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.below.product.minimum",
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is below the product minimum of " + min.stripTrailingZeros().toPlainString()
+ ".",
installment, min);
}
final BigDecimal max = limits.getMaxDepositAmount();
if (max != null && max.signum() > 0 && installment.compareTo(max) > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.installment.above.product.maximum",
"Installment amount " + installment.stripTrailingZeros().toPlainString() + " is above the product maximum of " + max.stripTrailingZeros().toPlainString()
+ ".",
installment, max);
}
}
private void validateNomination(final SavingsProduct product, final JsonCommand command) {
final boolean hasNominees = hasNominees(command);
if (product.isNominationFacility() && !hasNominees) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nominee.required",
"Product '" + product.getName() + "' has Nomination Facility enabled - add at least one nominee.");
}
if (!product.isNominationFacility() && hasNominees) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nomination.not.offered",
"Product '" + product.getName() + "' does not offer a Nomination Facility, so nominees cannot be recorded.");
}
}
/**
* "Introducer Required" on the product: the deposit must name an introducer - an existing, active customer of the
* bank, and not the depositor themselves.
*/
private void validateIntroducer(final RecurringDepositAccount account, final SavingsProduct product) {
if (!product.isIntroducerRequired()) {
return;
}
final Long introducerId = account.getIntroducerClientId();
if (introducerId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.required",
"Product '" + product.getName() + "' requires an introducer - choose an existing customer as introducer.");
}
if (introducerId.equals(account.clientId())) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.self",
"The depositor cannot be their own introducer.");
}
final Integer active = this.jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_client WHERE id = ? AND status_enum = 300",
Integer.class, introducerId);
if (active == null || active == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.introducer.not.active",
"The introducer must be an active customer of the bank.");
}
}
private void validateAutoDebit(final RecurringDepositAccount account, final SavingsProduct product) {
if (!Boolean.TRUE.equals(account.getAutoDebitFacility())) {
return;
}
if (!product.isAutoDebitFacility()) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.not.offered",
"Product '" + product.getName() + "' does not offer Auto Debit.");
}
final Long linkedAccountId = account.getLinkedAccountId();
if (linkedAccountId == null || !LINKED_ACCOUNT_TYPE_SAVINGS.equalsIgnoreCase(account.getLinkedAccountType())) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.required",
"Auto Debit collects each installment from the linked account, so link an active Savings account to use it.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_savings_account WHERE id = ? AND client_id = ? AND deposit_type_enum = 100 AND status_enum = 300",
Integer.class, linkedAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.auto.debit.linked.savings.not.active",
"Linked account " + linkedAccountId + " is not an active Savings account of this client, so Auto Debit cannot use it.",
linkedAccountId);
}
}
private static boolean hasNomineesProperty(final JsonCommand command) {
final JsonObject root = command.parsedJson().getAsJsonObject();
return root.has("nominees") || root.has("nominee");
}
private static boolean hasNominees(final JsonCommand command) {
final JsonObject root = command.parsedJson().getAsJsonObject();
final JsonElement nominees = root.get("nominees");
if (nominees != null && nominees.isJsonArray()) {
return !nominees.getAsJsonArray().isEmpty();
}
final JsonElement nominee = root.get("nominee");
return nominee != null && nominee.isJsonObject();
}
}
@@ -374,10 +374,8 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
// this runs per account inside a batch job, so a null deposit type must not take the whole run down.
final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
// A simple-interest recurring deposit must post here exactly what its own maturity projection promised, so the
// flag is the same one SavingsAccount#calculateInterestUsing honours.
this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods,
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled, savingsAccountData.isSimpleInterest());
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled);
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
@@ -438,12 +438,10 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
LocalDate yesterday = DateUtils.getBusinessLocalDate().minusDays(1);
// A product on the daily-interest timetable is paid by the pooled posting instead
// (SavingsPooledInterestPostingService). Posting it here too would pay its members twice.
// FD products are never on that timetable, so they still post here. A recurring deposit (deposit_type_enum
// 300) is left out: its posting periods are credited by the nightly deposit maturity job instead
// (RecurringDepositAccount#postPeriodicInterest), at the chart rate and on deposits alone. The filters sit inside the paging subquery so a page is never cut short by
// accounts that are then dropped.
// FD and RD products are never on that timetable, so they still post here. The filter sits inside the paging
// subquery so a page is never cut short by accounts that are then dropped.
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 "
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
+ "and not exists (select 1 from m_savings_interest_posting_schedule s "
+ "where s.savings_product_id = a.product_id and s.is_active = true) limit ?) b on b.id = sa.id ";
if (backdatedTxnsAllowedTill) {
@@ -472,7 +470,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, ");
sqlBuilder.append("sa.deposit_type_enum as depositType, ");
sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, ");
sqlBuilder.append("sa.is_simple_interest as isSimpleInterest, ");
sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, ");
sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, ");
sqlBuilder.append("sa.status_enum as statusEnum, ");
@@ -842,7 +839,6 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
// Monthly-payout deposits must not compound: their interest leaves the account every
// period, so each period earns on the principal alone.
savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount"));
savingsAccountData.setSimpleInterest(rs.getBoolean("isSimpleInterest"));
}
if (!transMap.containsValue(transactionId)) {
@@ -106,8 +106,6 @@ import org.apache.fineract.commands.domain.CommandSourceRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductFacilityLimitRepository;
import org.apache.fineract.portfolio.savings.domain.SavingsProductRepository;
import org.apache.fineract.portfolio.savings.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.service.RecurringDepositLienService;
import org.apache.fineract.portfolio.savings.service.RecurringDepositProductRules;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformService;
import org.apache.fineract.portfolio.savings.service.DepositAccountInterestRateChartReadPlatformServiceImpl;
import org.apache.fineract.portfolio.savings.service.DepositAccountOnHoldTransactionReadPlatformService;
@@ -299,8 +297,7 @@ public class SavingsConfiguration {
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate,
RecurringDepositLienService recurringDepositLienService, RecurringDepositProductRules recurringDepositProductRules
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -310,7 +307,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter, jdbcTemplate, recurringDepositLienService, recurringDepositProductRules);
denominationBreakdownWriter, jdbcTemplate);
}
@Bean
@@ -326,14 +323,13 @@ public class SavingsConfiguration {
FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository,
ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler,
ClientNomineeWritePlatformService clientNomineeWritePlatformService,
RecurringDepositProductRules recurringDepositProductRules) {
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
return new DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepository,
fixedDepositAccountRepository, recurringDepositAccountRepository, depositAccountAssembler, depositAccountDataValidator,
accountNumberGenerator, clientRepository, groupRepository, savingsProductRepository, noteRepository, staffRepository,
savingsAccountApplicationTransitionApiJsonValidator, savingsAccountChargeAssembler, accountAssociationsRepository,
fromJsonHelper, calendarInstanceRepository, configurationDomainService, accountNumberFormatRepository,
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules);
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService);
}
@Bean
@@ -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>
@@ -167,9 +167,4 @@
<include file="parts/0592_enable_teller_cashier_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0592_tds_declarations_and_challans.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_tds_interest_receipts_and_deductions.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_recurring_deposit_product_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0595_recurring_deposit_lien_percentage.xml" relativeToChangelogFile="true"/>
<include file="parts/0596_recurring_deposit_missed_installment_penalty.xml" relativeToChangelogFile="true"/>
<include file="parts/0597_enable_recurring_deposit_product_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0598_recurring_deposit_extend_tenure_permission.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>
@@ -1,89 +0,0 @@
<?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.9.xsd">
<!--
Recurring deposit product settings that used to be stored and ignored.
1. Interest Calculation Type gains a "Simple" option. A recurring deposit opened under a product set to
Simple never compounds its interest. The choice is copied onto the account when it opens
(is_simple_interest) and kept for its life, like its chart and compounding period, so a later product
edit cannot change what an open deposit was promised. Every existing account defaults to false -
compounding - which is how all of them were calculated until now, so nothing already open moves.
2. RD Auto Debit job: collects each due installment of a deposit opened with Auto Debit from the
Savings account linked to it.
-->
<changeSet author="fineract" id="0594-1-savings-account-is-simple-interest">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_account" columnName="is_simple_interest"/>
</not>
</preConditions>
<addColumn tableName="m_savings_account">
<column name="is_simple_interest" type="BOOLEAN" defaultValueBoolean="false">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0594-2-interest-calculation-type-simple">
<!-- Matched by label in code (RecurringDepositProductRules), so a tenant that already added its own
"Simple" value keeps it and no duplicate is created. -->
<sql>
INSERT INTO m_code_value (code_id, code_value, code_description, order_position, code_score, is_active, is_mandatory)
SELECT c.id, 'Simple', 'Simple', 3, NULL, true, false FROM m_code c
WHERE c.code_name = 'InterestCalculationTypeOption'
AND NOT EXISTS (SELECT 1 FROM m_code_value v WHERE v.code_id = c.id AND LOWER(v.code_value) = 'simple');
</sql>
</changeSet>
<changeSet author="fineract" id="0594-3-execute-rd-auto-debit-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Execute RD Auto Debit'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Execute RD Auto Debit"/>
<column name="display_name" value="Execute RD Auto Debit"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="RDAUTODB"/>
<!-- 00:30 daily: after the midnight business-date sync, so "due today" means the new day. Anything a
run cannot collect stays overdue and is retried by the next run. -->
<column name="cron_expression" value="0 30 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Execute RD Auto DebitJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</changeSet>
</databaseChangeLog>
@@ -1,67 +0,0 @@
<?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.9.xsd">
<!--
Lien Marked's loan limit: the most a deposit may secure, as a percentage of its balance (e.g. 90 = a loan of
up to 90% of what is in the deposit). Null keeps today's behaviour - the whole unpledged balance.
Added to the product and to the pending-edit table, since a product edit waits there for approval and is
replayed from it; a column only on the product would be dropped by every approved edit.
-->
<changeSet author="fineract" id="0595-1-savings-product-lien-percentage">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product" columnName="lien_percentage"/>
</not>
</preConditions>
<addColumn tableName="m_savings_product">
<column name="lien_percentage" type="DECIMAL(5,2)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0595-2-saving-product-temp-lien-percentage">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_saving_product_temp"/>
<not>
<columnExists tableName="m_saving_product_temp" columnName="lien_percentage"/>
</not>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="lien_percentage" type="DECIMAL(5,2)"/>
</addColumn>
</changeSet>
<!-- The deposit's own limit, chosen when it is opened (never above its product's). Null = product's. -->
<changeSet author="fineract" id="0595-3-savings-account-lien-percentage">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_account" columnName="lien_percentage"/>
</not>
</preConditions>
<addColumn tableName="m_savings_account">
<column name="lien_percentage" type="DECIMAL(5,2)"/>
</addColumn>
</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>
@@ -1,128 +0,0 @@
<?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.9.xsd">
<!--
Missed-installment penalty for recurring deposits ("Penalty / Grace Period for Missed Installments").
The product gains grace days, a penalty type (1 = flat amount, 2 = % of the installment) and the amount -
on the product and on the pending-edit table, since a product edit waits there for approval and is replayed
from it. A daily job charges each installment still unpaid after its grace days once, using the system
charge seeded below, and records it in m_rd_installment_penalty so it is never charged twice.
-->
<changeSet author="fineract" id="0596-1-savings-product-missed-installment-penalty">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_product" columnName="missed_installment_penalty_amount"/>
</not>
</preConditions>
<addColumn tableName="m_savings_product">
<column name="missed_installment_grace_days" type="INT"/>
<column name="missed_installment_penalty_type" type="INT"/>
<column name="missed_installment_penalty_amount" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0596-2-saving-product-temp-missed-installment-penalty">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_saving_product_temp"/>
<not>
<columnExists tableName="m_saving_product_temp" columnName="missed_installment_penalty_amount"/>
</not>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="missed_installment_grace_days" type="INT"/>
<column name="missed_installment_penalty_type" type="INT"/>
<column name="missed_installment_penalty_amount" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
<changeSet author="fineract" id="0596-3-rd-installment-penalty-table">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_rd_installment_penalty"/>
</not>
</preConditions>
<createTable tableName="m_rd_installment_penalty">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<!-- One penalty per installment, ever. -->
<column name="installment_id" type="BIGINT">
<constraints nullable="false" unique="true" uniqueConstraintName="uq_rd_installment_penalty_installment"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false" foreignKeyName="fk_rd_installment_penalty_account"
referencedTableName="m_savings_account" referencedColumnNames="id"/>
</column>
<column name="savings_account_charge_id" type="BIGINT"/>
<column name="penalty_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="applied_on_date" type="DATE">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
<changeSet author="fineract" id="0596-4-rd-missed-installment-penalty-charge">
<!-- Specified-due-date savings penalty in the organisation's currency; the amount is set per installment by
the job, so the definition's own amount stays 0. Matched by name, so a tenant's own copy is reused. -->
<sql>
INSERT INTO m_charge (name, currency_code, charge_applies_to_enum, charge_time_enum, charge_calculation_enum,
amount, is_penalty, is_active, is_deleted)
SELECT 'RD Missed Installment Penalty', oc.code, 2, 2, 1, 0, true, true, false
FROM (SELECT code FROM m_organisation_currency ORDER BY id LIMIT 1) oc
WHERE NOT EXISTS (SELECT 1 FROM m_charge c WHERE c.name = 'RD Missed Installment Penalty' AND c.is_deleted = false);
</sql>
</changeSet>
<changeSet author="fineract" id="0596-5-apply-rd-missed-installment-penalty-job">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM job WHERE name = 'Apply RD Missed Installment Penalty'</sqlCheck>
</preConditions>
<insert tableName="job">
<column name="name" value="Apply RD Missed Installment Penalty"/>
<column name="display_name" value="Apply RD Missed Installment Penalty"/>
<!-- short_name is NOT NULL and capped at 8 characters; see 0145_job_short_name.xml. -->
<column name="short_name" value="RDPENLTY"/>
<!-- 00:45 daily: after the business-date sync and after RD Auto Debit (00:30), so an installment the
auto debit collects tonight is never penalised. -->
<column name="cron_expression" value="0 45 0 1/1 * ? *"/>
<column name="create_time" valueDate="${current_datetime}"/>
<column name="task_priority" valueNumeric="5"/>
<column name="group_name"/>
<column name="previous_run_start_time"/>
<column name="job_key" value="Apply RD Missed Installment PenaltyJobDetail1 _ DEFAULT"/>
<column name="initializing_errorlog"/>
<column name="is_active" valueBoolean="true"/>
<column name="currently_running" valueBoolean="false"/>
<column name="updates_allowed" valueBoolean="true"/>
<column name="scheduler_group" valueNumeric="1"/>
<column name="is_misfired" valueBoolean="false"/>
<column name="node_id" valueNumeric="1"/>
<column name="is_mismatched_job" valueBoolean="true"/>
</insert>
</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>
@@ -1,88 +0,0 @@
<?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.9.xsd">
<!--
Recurring deposit product creation goes to the checker like every other product. 0472 covered loan, savings,
current and share products and 0474 fixed deposits; recurring deposits were left out, so a new RD product
went live the moment it was saved.
-->
<changeSet id="0597_add_recurringdepositproduct_checker_permission_if_not_exists" author="fineract">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'portfolio', 'CREATE_RECURRINGDEPOSITPRODUCT_CHECKER', 'RECURRINGDEPOSITPRODUCT', 'CREATE_CHECKER', FALSE
WHERE NOT EXISTS (
SELECT 1 FROM m_permission WHERE code = 'CREATE_RECURRINGDEPOSITPRODUCT_CHECKER'
);
</sql>
</changeSet>
<changeSet id="0597_enable_recurring_deposit_product_creation_maker_checker" author="fineract">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="1">
SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_RECURRINGDEPOSITPRODUCT'
</sqlCheck>
</preConditions>
<sql>
UPDATE m_permission
SET can_maker_checker = TRUE
WHERE code = 'CREATE_RECURRINGDEPOSITPRODUCT';
</sql>
</changeSet>
<changeSet id="0597_grant_recurring_deposit_product_creation_maker_checker_permissions_to_roles" author="fineract">
<sql>
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_RECURRINGDEPOSITPRODUCT', 'CREATE_RECURRINGDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = r.id AND rp.permission_id = p.id
);
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.code IN ('CREATE_RECURRINGDEPOSITPRODUCT', 'CREATE_RECURRINGDEPOSITPRODUCT_CHECKER')
AND NOT EXISTS (
SELECT 1 FROM m_role_permission rp
WHERE rp.role_id = ur.role_id AND rp.permission_id = p.id
);
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 IN ('CREATE_RECURRINGDEPOSITPRODUCT', 'CREATE_RECURRINGDEPOSITPRODUCT_CHECKER')
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>
@@ -1,51 +0,0 @@
<?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.9.xsd">
<!--
Tenure Flexibility: extending a running recurring deposit's term. Goes to the checker like a premature closure,
and is granted to whichever roles may prematurely close a recurring deposit.
-->
<changeSet id="0598_add_recurring_deposit_extend_tenure_permissions" author="fineract">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT', 'RECURRINGDEPOSITACCOUNT', 'EXTENDTENURE', TRUE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT');
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT_CHECKER', 'RECURRINGDEPOSITACCOUNT', 'EXTENDTENURE_CHECKER', FALSE
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'EXTENDTENURE_RECURRINGDEPOSITACCOUNT_CHECKER');
</sql>
</changeSet>
<changeSet id="0598_grant_recurring_deposit_extend_tenure_permissions" author="fineract">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT DISTINCT rp.role_id, np.id
FROM m_role_permission rp
JOIN m_permission op ON op.id = rp.permission_id
JOIN m_permission np ON np.code = REPLACE(op.code, 'PREMATURECLOSE_', 'EXTENDTENURE_')
WHERE op.code IN ('PREMATURECLOSE_RECURRINGDEPOSITACCOUNT', 'PREMATURECLOSE_RECURRINGDEPOSITACCOUNT_CHECKER')
AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = np.id);
</sql>
</changeSet>
</databaseChangeLog>
@@ -511,33 +511,6 @@ public class CommandSourceServiceTest {
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_RecurringDepositProduct_SuperUser_EnforcesMakerChecker() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
AppUser superUser = Mockito.mock(AppUser.class);
Mockito.when(commandSource.getPermissionCode()).thenReturn("CREATE_RECURRINGDEPOSITPRODUCT");
Mockito.when(commandSource.getEntityName()).thenReturn("RECURRINGDEPOSITPRODUCT");
Mockito.when(commandSource.getActionName()).thenReturn("CREATE");
Mockito.when(commandSource.getId()).thenReturn(115L);
Mockito.when(commandSource.getResourceId()).thenReturn(215L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("CREATE_RECURRINGDEPOSITPRODUCT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult dummyResult = new org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder()
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
public void testProcessCommand_FixedDepositProduct_CheckerApproval_MarksAsChecked() {
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
@@ -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
@@ -233,11 +233,7 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(INTERESTRATE_CHART_RESOURCE_NAME);
// A chart edited on its own (PUT /interestratecharts/{id}) has no sibling charts to check overlap against and
// no product to read a currency from: new slabs take the currency of the slabs already on the chart.
final String currencyCode = setOfChartSlabs().stream().map(slab -> slab.slabFields().getCurrencyCode())
.filter(Objects::nonNull).findFirst().orElse(null);
this.update(command, actualChanges, baseDataValidator, Set.of(), currencyCode);
this.update(command, actualChanges, baseDataValidator, null, null);
throwExceptionIfValidationWarningsExist(dataValidationErrors);
@@ -66,10 +66,6 @@ public class InterestRateChartSlabFields {
return this.amountRangeTo;
}
public String getCurrencyCode() {
return this.currencyCode;
}
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;

Some files were not shown because too many files have changed in this diff Show More