Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d10ec60f8 | ||
|
|
14973dfd5e | ||
|
|
a127710442 | ||
|
|
919bfb5204 | ||
|
|
39e3fcec59 | ||
|
|
b726577031 | ||
|
|
d1e94fd6a3 | ||
|
|
22c4403a14 |
+3
-1
@@ -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;
|
||||
|
||||
+8
-9
@@ -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";
|
||||
|
||||
+1
@@ -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;
|
||||
|
||||
-2
@@ -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;
|
||||
|
||||
-10
@@ -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;
|
||||
|
||||
-14
@@ -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,
|
||||
|
||||
-6
@@ -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);
|
||||
}
|
||||
|
||||
+1
-35
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-5
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
-12
@@ -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;
|
||||
|
||||
+4
-11
@@ -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();
|
||||
|
||||
+2
-41
@@ -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
|
||||
|
||||
+189
@@ -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;
|
||||
}
|
||||
}
|
||||
+30
@@ -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();
|
||||
}
|
||||
}
|
||||
+14
@@ -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;
|
||||
}
|
||||
|
||||
+471
@@ -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 & 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();
|
||||
}
|
||||
}
|
||||
+16
@@ -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);
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+94
@@ -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;
|
||||
}
|
||||
}
|
||||
+24
@@ -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();
|
||||
}
|
||||
+52
@@ -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;
|
||||
}
|
||||
+17
@@ -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();
|
||||
}
|
||||
+98
@@ -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;
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+3
-28
@@ -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 {} = {}]",
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-7
@@ -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());
|
||||
|
||||
-29
@@ -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);
|
||||
|
||||
-34
@@ -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 ────
|
||||
|
||||
/**
|
||||
|
||||
-30
@@ -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;
|
||||
|
||||
|
||||
+10
-96
@@ -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) {
|
||||
|
||||
+1
-8
@@ -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);
|
||||
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+57
-2
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+134
@@ -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);
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
}
|
||||
+28
@@ -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;
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
+33
@@ -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;
|
||||
}
|
||||
+37
@@ -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);
|
||||
}
|
||||
}
|
||||
+8
@@ -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,
|
||||
|
||||
+164
@@ -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;
|
||||
}
|
||||
}
|
||||
+514
@@ -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;
|
||||
}
|
||||
}
|
||||
+11
@@ -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) //
|
||||
|
||||
+28
@@ -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();
|
||||
}
|
||||
+130
@@ -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();
|
||||
}
|
||||
}
|
||||
+17
@@ -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);
|
||||
}
|
||||
+200
@@ -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);
|
||||
}
|
||||
}
|
||||
+4
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+3
-5
@@ -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.");
|
||||
|
||||
+45
-63
@@ -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);
|
||||
|
||||
+1
-4
@@ -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);
|
||||
|
||||
-205
@@ -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);
|
||||
}
|
||||
}
|
||||
-80
@@ -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);
|
||||
}
|
||||
}
|
||||
-36
@@ -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);
|
||||
|
||||
-3
@@ -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);
|
||||
}
|
||||
|
||||
+15
-164
@@ -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";
|
||||
|
||||
+1293
-1569
File diff suppressed because it is too large
Load Diff
-43
@@ -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);
|
||||
}
|
||||
}
|
||||
-62
@@ -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();
|
||||
}
|
||||
}
|
||||
-60
@@ -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();
|
||||
}
|
||||
}
|
||||
-42
@@ -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;
|
||||
}
|
||||
}
|
||||
-10
@@ -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;
|
||||
|
||||
-8
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
-3
@@ -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();
|
||||
|
||||
-15
@@ -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;
|
||||
}
|
||||
|
||||
+3
-138
@@ -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
|
||||
|
||||
-7
@@ -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()) {
|
||||
|
||||
-8
@@ -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);
|
||||
|
||||
+27
-140
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
-157
@@ -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) {}
|
||||
}
|
||||
-176
@@ -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();
|
||||
}
|
||||
}
|
||||
-242
@@ -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);
|
||||
}
|
||||
}
|
||||
-301
@@ -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();
|
||||
}
|
||||
}
|
||||
+1
-3
@@ -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);
|
||||
|
||||
|
||||
+3
-7
@@ -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)) {
|
||||
|
||||
+4
-8
@@ -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>
|
||||
|
||||
+112
@@ -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>
|
||||
+71
@@ -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>
|
||||
+193
@@ -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>
|
||||
+133
@@ -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>
|
||||
+199
@@ -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 & 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>
|
||||
-89
@@ -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>
|
||||
-67
@@ -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>
|
||||
+95
@@ -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 & 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>
|
||||
+111
@@ -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>
|
||||
-128
@@ -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>
|
||||
+90
@@ -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>
|
||||
-88
@@ -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>
|
||||
-51
@@ -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>
|
||||
-27
@@ -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
|
||||
|
||||
+1
-5
@@ -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);
|
||||
|
||||
|
||||
-4
@@ -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
Reference in New Issue
Block a user