feat: recurring deposit product rules, lien, auto debit, missed installment penalty, and jobs
This commit is contained in:
+1
@@ -163,6 +163,7 @@ public class CommandSourceService {
|
||||
|| "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;
|
||||
|
||||
+9
@@ -3157,6 +3157,15 @@ 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";
|
||||
|
||||
+2
@@ -75,6 +75,8 @@ 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,6 +432,10 @@ 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");
|
||||
@@ -491,6 +495,10 @@ 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");
|
||||
@@ -648,6 +656,8 @@ 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,6 +234,20 @@ 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,6 +180,12 @@ 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);
|
||||
}
|
||||
|
||||
+35
-1
@@ -40,6 +40,18 @@ 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);
|
||||
@@ -47,7 +59,8 @@ public class CompoundInterestHelper {
|
||||
// total interest earned in previous periods but not yet recognised
|
||||
BigDecimal compoundedInterest = BigDecimal.ZERO;
|
||||
BigDecimal unCompoundedInterest = BigDecimal.ZERO;
|
||||
final CompoundInterestValues compoundInterestValues = new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
final CompoundInterestValues compoundInterestValues = simpleInterest ? new SimpleInterestValues()
|
||||
: new CompoundInterestValues(compoundedInterest, unCompoundedInterest);
|
||||
for (final PostingPeriod postingPeriod : allPeriods) {
|
||||
|
||||
final BigDecimal interestEarnedThisPeriod = postingPeriod.calculateInterest(compoundInterestValues);
|
||||
@@ -67,4 +80,25 @@ 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,4 +50,9 @@ 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,6 +94,18 @@ 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;
|
||||
|
||||
+11
-4
@@ -301,10 +301,17 @@ public final class PostingPeriod {
|
||||
// to be applied to the balanced for interest calculation
|
||||
for (final CompoundingPeriod compoundingPeriod : this.compoundingPeriods) {
|
||||
|
||||
final BigDecimal interestUnrounded = compoundingPeriod.calculateInterest(this.interestCompoundingType,
|
||||
this.interestCalculationType, compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
|
||||
this.daysInYear, this.minBalanceForInterestCalculation.getAmount(), this.overdraftInterestRateAsFraction,
|
||||
this.minOverdraftForInterestCalculation.getAmount());
|
||||
// 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());
|
||||
BigDecimal unCompoundedInterest = compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
|
||||
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
|
||||
LocalDate compoundingPeriodEndDate = compoundingPeriod.getPeriodInterval().endDate();
|
||||
|
||||
+28
-3
@@ -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 LocalDate transactionDate, final BigDecimal amount, final boolean isReversal, final boolean payableCarriesAccrual) {
|
||||
|
||||
final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId);
|
||||
@@ -665,10 +665,14 @@ public class AccountingProcessorHelper {
|
||||
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
|
||||
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
|
||||
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
|
||||
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Recurring 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", "Interest Paid", "Interest Paid Account"))
|
||||
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Recurring Deposit Interest Paid", "Interest Paid",
|
||||
"Interest Paid Account"))
|
||||
.orElse(payableAccount);
|
||||
|
||||
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
|
||||
@@ -706,6 +710,19 @@ 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={}",
|
||||
@@ -718,6 +735,14 @@ 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);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+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);
|
||||
savingsId, transactionId, transactionDate, postingAmount, effectiveIsReversal, false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+7
-1
@@ -294,8 +294,14 @@ 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);
|
||||
if (null != isDormancyTrackingActive && isDormancyTrackingActive) {
|
||||
final Boolean dormancyAlert = this.fromApiJsonHelper.parameterExists("dormancyAlert", element)
|
||||
? this.fromApiJsonHelper.extractBooleanNamed("dormancyAlert", element)
|
||||
: null;
|
||||
if (Boolean.TRUE.equals(isDormancyTrackingActive) || Boolean.TRUE.equals(dormancyAlert)) {
|
||||
this.savingsProductToGLAccountMappingHelper.saveSavingsToLiabilityAccountMapping(element,
|
||||
SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingProductId,
|
||||
CashAccountsForSavings.ESCHEAT_LIABILITY.getValue());
|
||||
|
||||
+29
@@ -27,6 +27,7 @@ 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;
|
||||
@@ -44,6 +45,7 @@ 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;
|
||||
@@ -74,6 +76,7 @@ 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;
|
||||
@@ -149,6 +152,7 @@ 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(),
|
||||
@@ -204,6 +208,7 @@ 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);
|
||||
@@ -409,6 +414,7 @@ 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);
|
||||
@@ -454,6 +460,7 @@ 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);
|
||||
@@ -488,6 +495,7 @@ 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(
|
||||
@@ -649,6 +657,26 @@ 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();
|
||||
}
|
||||
@@ -857,6 +885,7 @@ 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,11 +789,13 @@ 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);
|
||||
@@ -801,6 +803,38 @@ 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,6 +176,7 @@ 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;
|
||||
@@ -486,6 +487,8 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -932,6 +935,7 @@ 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) {
|
||||
@@ -944,6 +948,7 @@ 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)
|
||||
@@ -1193,6 +1198,31 @@ 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;
|
||||
|
||||
|
||||
+8
-1
@@ -55,6 +55,7 @@ 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;
|
||||
@@ -79,6 +80,7 @@ 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,
|
||||
@@ -86,7 +88,9 @@ 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 AccountAssociationsRepository accountAssociationsRepository, final GuarantorDomainService guarantorDomainService,
|
||||
final RecurringDepositLienService recurringDepositLienService) {
|
||||
this.recurringDepositLienService = recurringDepositLienService;
|
||||
this.loanRepositoryWrapper = loanRepositoryWrapper;
|
||||
this.clientRepositoryWrapper = clientRepositoryWrapper;
|
||||
this.groupRepositoryWrapper = groupRepositoryWrapper;
|
||||
@@ -120,6 +124,9 @@ 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);
|
||||
|
||||
|
||||
+5
-3
@@ -71,9 +71,11 @@ public class RbiReserveConfigApiResource {
|
||||
static {
|
||||
ROLE_DESCRIPTIONS.put("CASH_IN_HAND",
|
||||
"Cash the bank itself holds - vault and teller balances. Counts toward CRR maintained.");
|
||||
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.");
|
||||
// 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("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.");
|
||||
|
||||
+63
-45
@@ -1286,16 +1286,22 @@ public class RbiReserveReturnApiResource {
|
||||
final Rate slr) {
|
||||
final List<Map<String, Object>> lines = new ArrayList<>();
|
||||
|
||||
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",
|
||||
// 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.",
|
||||
"FORM1_I_A_I_SBI_CURRENT", dates, officeId, 2);
|
||||
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system",
|
||||
final List<BigDecimal> iaii = formOneLine(lines, "(ii) Total of other demand liabilites 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
|
||||
@@ -1307,23 +1313,25 @@ 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) Current account balances with SBI, its subsidiaries and the corresponding new banks",
|
||||
final List<BigDecimal> iiiA = formOneLine(lines,
|
||||
"(A) TOTAL OF CREDIT BALANCES IN CURRENT ACCOUNT maintained with S.B.I, Subsidiary Banks & corresponding new banks",
|
||||
"FORM1_III_A_SBI_CURRENT", dates, officeId, 1);
|
||||
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)",
|
||||
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) :",
|
||||
"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 and any other assets",
|
||||
final List<BigDecimal> iiiBiv = formOneLine(lines, "(iv) Advances & 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",
|
||||
@@ -1338,7 +1346,9 @@ 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 AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [CURRENT FORTNIGHT]", iv));
|
||||
"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));
|
||||
final List<BigDecimal> ivBase = new ArrayList<>();
|
||||
final List<String> ivBaseDates = new ArrayList<>();
|
||||
for (int i = 0; i < dates.size(); i++) {
|
||||
@@ -1346,33 +1356,38 @@ 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) TOTAL (NET) DEMAND AND TIME LIABILITIES FOR THE PURPOSE OF SECTIONS 18 AND 24 [SECOND PRECEDING FORTNIGHT]",
|
||||
"IV. b) DEMAND AND TIME LIABILITY OF BASE DATE FOR THE PURPOSE OF SEC.18 & 24",
|
||||
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-operative Bank of the state concerned",
|
||||
final List<BigDecimal> viB = formOneLine(lines, "(b) State Co.Op.Bank of the State Concerned :",
|
||||
"FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1);
|
||||
final List<BigDecimal> viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned",
|
||||
final List<BigDecimal> viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.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. BALANCES OF ALL OTHER TYPES WITH"));
|
||||
final List<BigDecimal> viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned",
|
||||
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 :",
|
||||
"FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1);
|
||||
final List<BigDecimal> viiB = formOneLine(lines, "(b) District Central Co-operative Bank",
|
||||
final List<BigDecimal> viiB = formOneLine(lines, "(B) CENTRAL CO.OP.BANK OF THE DIST.CONCERNED",
|
||||
"FORM1_VII_B_DCCB_OTHER", dates, officeId, 1);
|
||||
final List<BigDecimal> viiC = formOneLine(lines, "(c) Investment in Government securities",
|
||||
final List<BigDecimal> viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY",
|
||||
"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.
|
||||
@@ -1380,7 +1395,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 — excess of III(A) over I(a)(i)", viii));
|
||||
lines.add(formOneTotal("VIII. Net balance in current accounts i.e.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.
|
||||
@@ -1417,12 +1432,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 REQUIRED TO BE MAINTAINED — " + (crr == null ? "rate not set" : crr.percentage + "% of IV(b)"),
|
||||
lines.add(formOneTotal("IX. " + (crr == null ? "CRR rate not set" : crr.percentage + " PERCENT") + " OF IV AS ON THE BASE DATE",
|
||||
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");
|
||||
part.put("title", "PART B : COMPLIANCE WITH SECTION 18. (Not applicable to scheduled state co-op. Bank)");
|
||||
part.put("lines", lines);
|
||||
part.put("required", required);
|
||||
part.put("maintained", maintained);
|
||||
@@ -1445,32 +1460,35 @@ 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 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.
|
||||
// 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.
|
||||
final BigDecimal crrRequired = formOnePartBRequired(date, officeId);
|
||||
final BigDecimal crrMaintained = formOnePartBMaintained(date, officeId);
|
||||
final BigDecimal excessCash = crrMaintained.subtract(crrRequired).max(BigDecimal.ZERO);
|
||||
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));
|
||||
final BigDecimal governmentSecurities = figureFor("FORM1_VII_C_GOVT_SECURITIES", date, officeId).amount;
|
||||
cashAndOther.add(excessCash.add(governmentSecurities));
|
||||
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 REQUIRED TO BE MAINTAINED — " + (slr == null ? "rate not set" : slr.percentage + "% of IV(b)"),
|
||||
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",
|
||||
required));
|
||||
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(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(formOneRow("(b) Gold", "FORMA_SLR_GOLD", gold, 1));
|
||||
lines.add(formOneRow("(c) Unencumbered approved securities", "SLR_INVESTMENTS", securities, 1));
|
||||
lines.add(formOneRow("(c) Unencombered approved Securites $$", "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");
|
||||
part.put("title", "PART C : COMPLIANCE WITH SECTION 24 (Not applicable to scheduled state co-op. banks)");
|
||||
part.put("lines", lines);
|
||||
part.put("required", required);
|
||||
part.put("maintained", totalXII);
|
||||
|
||||
+4
-1
@@ -361,6 +361,9 @@ 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);
|
||||
@@ -377,7 +380,7 @@ public class RecurringDepositAccountsApiResource {
|
||||
if (result == null) {
|
||||
throw new UnrecognizedQueryParamException("command", commandParam,
|
||||
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
|
||||
"postInterest", "close", "prematureClose", "calculatePrematureAmount", "extendTenure" });
|
||||
}
|
||||
|
||||
return this.toApiJsonSerializer.serialize(result);
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 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,6 +180,7 @@ public class DepositProductDataValidator {
|
||||
}
|
||||
|
||||
validateRecurringDetailForCreate(element, baseDataValidator);
|
||||
validateLienPercentage(element, baseDataValidator);
|
||||
|
||||
if (!hasRdSlabs) {
|
||||
validateChartsData(element, baseDataValidator);
|
||||
@@ -215,6 +216,7 @@ 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.
|
||||
@@ -225,6 +227,40 @@ 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,4 +67,7 @@ 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);
|
||||
}
|
||||
|
||||
+164
-15
@@ -72,6 +72,7 @@ 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;
|
||||
@@ -447,14 +448,47 @@ 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()) {
|
||||
reInvestAmount = account.getDepositAmount();
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
reInvestAmount = account.getAccountBalance();
|
||||
}
|
||||
@@ -470,26 +504,36 @@ 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.save(reinvestedDeposit);
|
||||
this.savingsAccountRepository.saveAndFlush(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, false, isRegularTransaction);
|
||||
paymentDetail, applyWithdrawFee, isRegularTransaction, isInternalTransferLeg);
|
||||
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);
|
||||
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);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "maturity", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
@@ -505,10 +549,28 @@ 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(
|
||||
@@ -1197,6 +1259,9 @@ 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);
|
||||
@@ -1205,16 +1270,12 @@ 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);
|
||||
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);
|
||||
transferRdPayoutToSavings(account, toSavingsAccount, closedDate, "premature closure", transferDescription, locale, fmt,
|
||||
isRegularTransaction);
|
||||
updateAlreadyPostedTransactions(existingTransactionIds, account);
|
||||
} else {
|
||||
final SavingsInterestTdsService.DirectPayout tds = this.savingsInterestTdsService.deductOnDirectPayout(account, closedDate,
|
||||
@@ -1228,9 +1289,32 @@ 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());
|
||||
@@ -1247,6 +1331,71 @@ 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";
|
||||
|
||||
+1569
-1293
File diff suppressed because it is too large
Load Diff
+43
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* 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,6 +61,16 @@ 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,6 +46,7 @@ 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 {
|
||||
@@ -117,12 +118,19 @@ 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,6 +61,9 @@ 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,6 +283,18 @@ 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() {
|
||||
|
||||
@@ -2057,6 +2069,8 @@ 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, ");
|
||||
@@ -2183,6 +2197,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
|
||||
rs.getBoolean("deceasedSettlement"),
|
||||
rs.getBoolean("allowDebitTxnAfterDueDate")
|
||||
);
|
||||
result.setLienPercentage(rs.getBigDecimal("lienPercentage"));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
+138
-3
@@ -24,6 +24,7 @@ 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;
|
||||
@@ -44,6 +45,7 @@ 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;
|
||||
@@ -147,6 +149,8 @@ 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
|
||||
@@ -639,7 +643,12 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
|
||||
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType);
|
||||
checkClientOrGroupActive(account);
|
||||
postInterest(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);
|
||||
}
|
||||
return new CommandProcessingResultBuilder() //
|
||||
.withEntityId(savingsId) //
|
||||
.withOfficeId(account.officeId()) //
|
||||
@@ -650,6 +659,28 @@ 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
|
||||
@@ -931,6 +962,7 @@ 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);
|
||||
|
||||
@@ -1047,6 +1079,43 @@ 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();
|
||||
@@ -1059,6 +1128,7 @@ 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)
|
||||
@@ -1589,11 +1659,76 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
|
||||
}
|
||||
}
|
||||
} else if (depositAccountType.isRecurringDeposit()) {
|
||||
((RecurringDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd,
|
||||
financialYearBeginningMonth, postReversals);
|
||||
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);
|
||||
}
|
||||
}
|
||||
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,6 +127,7 @@ 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.
|
||||
@@ -302,9 +303,12 @@ 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)
|
||||
@@ -551,12 +555,15 @@ 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,6 +499,10 @@ 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, ");
|
||||
@@ -623,6 +627,10 @@ 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);
|
||||
|
||||
+141
-28
@@ -29,19 +29,47 @@ 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.organisation.monetary.domain.MoneyHelper;
|
||||
import org.springframework.stereotype.Service;
|
||||
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;
|
||||
|
||||
@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) {
|
||||
@@ -53,27 +81,120 @@ public class RecurringDepositAccountInterestCalculationServiceImpl implements Re
|
||||
element);
|
||||
LocalDate submittedOnDate = this.fromApiJsonHelper.extractLocalDateNamed("submittedOnDate", element);
|
||||
if (submittedOnDate == null) {
|
||||
submittedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
|
||||
submittedOnDate = DateUtils.getBusinessLocalDate();
|
||||
}
|
||||
final Long productId = this.fromApiJsonHelper.extractLongNamed(PRODUCT_ID_PARAM, element);
|
||||
final ProductInterestTerms terms = productId == null ? null : productInterestTerms(productId);
|
||||
|
||||
BigDecimal maturityAmount = this.calculateInterestInternal(depositAmount, annualInterestRate, tenureInMonths,
|
||||
interestCompoundingPeriodInMonths, submittedOnDate);
|
||||
|
||||
final HashMap<String, Object> result = new HashMap<>();
|
||||
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 = "This is an approximate calculated amount - it may vary slightly when the account is created";
|
||||
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";
|
||||
|
||||
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();
|
||||
@@ -89,39 +210,31 @@ 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) {
|
||||
@@ -129,11 +242,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
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 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
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
}
|
||||
+3
-1
@@ -374,8 +374,10 @@ 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);
|
||||
getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled, savingsAccountData.isSimpleInterest());
|
||||
|
||||
savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods);
|
||||
|
||||
|
||||
+7
-3
@@ -438,10 +438,12 @@ 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 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.
|
||||
// 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.
|
||||
String sql = "select " + this.savingAccountMapperForInterestPosting.schema()
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? "
|
||||
+ "join (select a.id from m_savings_account a where a.id > ? and a.status_enum = ? and a.deposit_type_enum <> 300 "
|
||||
+ "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) {
|
||||
@@ -470,6 +472,7 @@ 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, ");
|
||||
@@ -839,6 +842,7 @@ 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)) {
|
||||
|
||||
+8
-4
@@ -106,6 +106,8 @@ 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;
|
||||
@@ -297,7 +299,8 @@ public class SavingsConfiguration {
|
||||
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
|
||||
WorkingDaysRepositoryWrapper workingDaysRepository,
|
||||
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
|
||||
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate,
|
||||
RecurringDepositLienService recurringDepositLienService, RecurringDepositProductRules recurringDepositProductRules
|
||||
|
||||
) {
|
||||
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
|
||||
@@ -307,7 +310,7 @@ public class SavingsConfiguration {
|
||||
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
|
||||
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
|
||||
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
|
||||
denominationBreakdownWriter, jdbcTemplate);
|
||||
denominationBreakdownWriter, jdbcTemplate, recurringDepositLienService, recurringDepositProductRules);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@@ -323,13 +326,14 @@ public class SavingsConfiguration {
|
||||
FromJsonHelper fromJsonHelper, CalendarInstanceRepository calendarInstanceRepository,
|
||||
ConfigurationDomainService configurationDomainService, AccountNumberFormatRepositoryWrapper accountNumberFormatRepository,
|
||||
BusinessEventNotifierService businessEventNotifierService, SavingsAccountAssembler savingsAccountAssembler,
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService) {
|
||||
ClientNomineeWritePlatformService clientNomineeWritePlatformService,
|
||||
RecurringDepositProductRules recurringDepositProductRules) {
|
||||
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);
|
||||
businessEventNotifierService, savingsAccountAssembler, clientNomineeWritePlatformService, recurringDepositProductRules);
|
||||
}
|
||||
|
||||
@Bean
|
||||
|
||||
@@ -167,4 +167,9 @@
|
||||
<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>
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<?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
@@ -0,0 +1,67 @@
|
||||
<?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>
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
<?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>
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
<?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
@@ -0,0 +1,51 @@
|
||||
<?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,6 +511,33 @@ 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);
|
||||
|
||||
+5
-1
@@ -233,7 +233,11 @@ public class InterestRateChart extends AbstractPersistableCustom<Long> {
|
||||
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
|
||||
.resource(INTERESTRATE_CHART_RESOURCE_NAME);
|
||||
|
||||
this.update(command, actualChanges, baseDataValidator, null, null);
|
||||
// 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);
|
||||
|
||||
throwExceptionIfValidationWarningsExist(dataValidationErrors);
|
||||
|
||||
|
||||
+4
@@ -66,6 +66,10 @@ public class InterestRateChartSlabFields {
|
||||
return this.amountRangeTo;
|
||||
}
|
||||
|
||||
public String getCurrencyCode() {
|
||||
return this.currencyCode;
|
||||
}
|
||||
|
||||
@Column(name = "amount_range_to", scale = 6, precision = 19)
|
||||
private BigDecimal amountRangeTo;
|
||||
|
||||
|
||||
+7
@@ -140,6 +140,8 @@ public final class RecurringDepositAccountData extends DepositAccountData {
|
||||
private Boolean prematureWithdrawalOption;
|
||||
private Boolean tenureFlexibility;
|
||||
private Boolean lienMarked;
|
||||
/** Template only: the product's Lien Marked loan limit (% of balance), the default and ceiling at opening. */
|
||||
private BigDecimal lienPercentage;
|
||||
private Boolean maturityInstructions;
|
||||
private Boolean creditMaturityProceeds;
|
||||
private Boolean penaltyGracePeriodMissedInstallments;
|
||||
@@ -173,6 +175,10 @@ public final class RecurringDepositAccountData extends DepositAccountData {
|
||||
|
||||
public void setJointHolders(java.util.List<java.util.Map<String, Object>> v) { this.jointHolders = v; }
|
||||
|
||||
public void setLienPercentage(final BigDecimal lienPercentage) {
|
||||
this.lienPercentage = lienPercentage;
|
||||
}
|
||||
|
||||
public void setCbsAccountFields(final String accountStatus, final String modeOfOperation,
|
||||
final Long introducerClientId, final String introducerName, final String introducerCifNumber,
|
||||
final String specialCategory, final String weakerSection,
|
||||
@@ -230,6 +236,7 @@ public final class RecurringDepositAccountData extends DepositAccountData {
|
||||
this.prematureWithdrawalOption = src.prematureWithdrawalOption;
|
||||
this.tenureFlexibility = src.tenureFlexibility;
|
||||
this.lienMarked = src.lienMarked;
|
||||
this.lienPercentage = src.lienPercentage;
|
||||
this.maturityInstructions = src.maturityInstructions;
|
||||
this.creditMaturityProceeds = src.creditMaturityProceeds;
|
||||
this.penaltyGracePeriodMissedInstallments = src.penaltyGracePeriodMissedInstallments;
|
||||
|
||||
+22
-2
@@ -90,6 +90,10 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
private Boolean prematureWithdrawalOption;
|
||||
private Boolean tenureFlexibility;
|
||||
private Boolean lienMarked;
|
||||
private BigDecimal lienPercentage;
|
||||
private Integer missedInstallmentGraceDays;
|
||||
private Integer missedInstallmentPenaltyType;
|
||||
private BigDecimal missedInstallmentPenaltyAmount;
|
||||
private Boolean maturityInstructions;
|
||||
private Boolean creditMaturityProceeds;
|
||||
private Boolean penaltyGracePeriodMissedInstallments;
|
||||
@@ -133,6 +137,10 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
public void setPrematureWithdrawalOption(Boolean v) { this.prematureWithdrawalOption = v; }
|
||||
public void setTenureFlexibility(Boolean v) { this.tenureFlexibility = v; }
|
||||
public void setLienMarked(Boolean v) { this.lienMarked = v; }
|
||||
public void setLienPercentage(BigDecimal v) { this.lienPercentage = v; }
|
||||
public void setMissedInstallmentGraceDays(Integer v) { this.missedInstallmentGraceDays = v; }
|
||||
public void setMissedInstallmentPenaltyType(Integer v) { this.missedInstallmentPenaltyType = v; }
|
||||
public void setMissedInstallmentPenaltyAmount(BigDecimal v) { this.missedInstallmentPenaltyAmount = v; }
|
||||
public void setMaturityInstructions(Boolean v) { this.maturityInstructions = v; }
|
||||
public void setCreditMaturityProceeds(Boolean v) { this.creditMaturityProceeds = v; }
|
||||
public void setPenaltyGracePeriodMissedInstallments(Boolean v) { this.penaltyGracePeriodMissedInstallments = v; }
|
||||
@@ -146,6 +154,8 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
/** Copy all CBS custom fields from source into this instance. */
|
||||
public void copyCbsFieldsFrom(final RecurringDepositProductData src) {
|
||||
if (src == null) return;
|
||||
this.setOtherGlMappings(src.getOtherGlMappings());
|
||||
this.setOtherGlMappingsMap(src.getOtherGlMappingsMap());
|
||||
this.accountType = src.accountType;
|
||||
this.customerSegment = src.customerSegment;
|
||||
this.productCode = src.productCode;
|
||||
@@ -174,6 +184,10 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
this.prematureWithdrawalOption = src.prematureWithdrawalOption;
|
||||
this.tenureFlexibility = src.tenureFlexibility;
|
||||
this.lienMarked = src.lienMarked;
|
||||
this.lienPercentage = src.lienPercentage;
|
||||
this.missedInstallmentGraceDays = src.missedInstallmentGraceDays;
|
||||
this.missedInstallmentPenaltyType = src.missedInstallmentPenaltyType;
|
||||
this.missedInstallmentPenaltyAmount = src.missedInstallmentPenaltyAmount;
|
||||
this.maturityInstructions = src.maturityInstructions;
|
||||
this.creditMaturityProceeds = src.creditMaturityProceeds;
|
||||
this.penaltyGracePeriodMissedInstallments = src.penaltyGracePeriodMissedInstallments;
|
||||
@@ -405,8 +419,9 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
|
||||
final Collection<TaxGroupData> taxGroupOptions = null;
|
||||
|
||||
return new RecurringDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
|
||||
depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate,
|
||||
final RecurringDepositProductData result = new RecurringDepositProductData(depositProductData.id, depositProductData.name,
|
||||
depositProductData.shortName, depositProductData.description, depositProductData.currency,
|
||||
depositProductData.nominalAnnualInterestRate,
|
||||
depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType,
|
||||
depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType,
|
||||
depositProductData.lockinPeriodFrequency, depositProductData.lockinPeriodFrequencyType,
|
||||
@@ -420,6 +435,11 @@ public final class RecurringDepositProductData extends DepositProductData {
|
||||
inMultiplesOfDepositTermType, isMandatoryDeposit, allowWithdrawal, adjustAdvanceTowardsFuturePayments,
|
||||
periodFrequencyTypeOptions, minDepositAmount, depositAmount, maxDepositAmount, depositProductData.withHoldTax,
|
||||
depositProductData.taxGroup, taxGroupOptions);
|
||||
// The product's named GL mappings (e.g. "Recurring Deposit Interest Paid"), as FixedDepositProductData carries them.
|
||||
// Without them the edit screen showed those rows blank and saving it dropped the mapping.
|
||||
result.setOtherGlMappings(depositProductData.getOtherGlMappings());
|
||||
result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap());
|
||||
return result;
|
||||
}
|
||||
|
||||
public static RecurringDepositProductData lookup(final Long id, final String name) {
|
||||
|
||||
+17
@@ -250,6 +250,18 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
|
||||
}
|
||||
}
|
||||
|
||||
/** The premature-closure penalty set to exactly the product's: applicable or not, the percentage and its period. */
|
||||
public void applyProductPreClosureDetail(final DepositPreClosureDetail productDetail) {
|
||||
if (productDetail == null) {
|
||||
return;
|
||||
}
|
||||
this.preClosureDetail = DepositPreClosureDetail.createFrom(productDetail.preClosurePenalApplicable(),
|
||||
productDetail.preClosurePenalApplicable() ? productDetail.preClosurePenalInterest() : null,
|
||||
productDetail.preClosurePenalApplicable() && productDetail.preClosurePenalInterestOnTypeId() != null
|
||||
? productDetail.preClosurePenalInterestOnType()
|
||||
: null);
|
||||
}
|
||||
|
||||
public DepositPreClosureDetail depositPreClosureDetail() {
|
||||
if (this.preClosureDetail == null) {
|
||||
this.preClosureDetail = new DepositPreClosureDetail();
|
||||
@@ -272,6 +284,11 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom<L
|
||||
return this.depositPeriod;
|
||||
}
|
||||
|
||||
/** A running deposit's term lengthened (Tenure Flexibility); the period stays in its existing unit. */
|
||||
public void extendDepositPeriod(final Integer newDepositPeriod) {
|
||||
this.depositPeriod = newDepositPeriod;
|
||||
}
|
||||
|
||||
public Integer depositPeriodFrequency() {
|
||||
return this.depositPeriodFrequency;
|
||||
}
|
||||
|
||||
+13
@@ -393,6 +393,19 @@ public class DepositProductAssembler {
|
||||
recurringDepositProduct.setPrematureWithdrawalOption(command.booleanPrimitiveValueOfParameterNamed("prematureWithdrawalOption"));
|
||||
recurringDepositProduct.setTenureFlexibility(command.booleanPrimitiveValueOfParameterNamed("tenureFlexibility"));
|
||||
recurringDepositProduct.setLienMarked(command.booleanPrimitiveValueOfParameterNamed("lienMarked"));
|
||||
if (command.parameterExists("lienPercentage")) {
|
||||
recurringDepositProduct.setLienPercentage(command.bigDecimalValueOfParameterNamed("lienPercentage"));
|
||||
}
|
||||
if (command.parameterExists("missedInstallmentGraceDays")) {
|
||||
recurringDepositProduct.setMissedInstallmentGraceDays(command.integerValueOfParameterNamed("missedInstallmentGraceDays"));
|
||||
}
|
||||
if (command.parameterExists("missedInstallmentPenaltyType")) {
|
||||
recurringDepositProduct.setMissedInstallmentPenaltyType(command.integerValueOfParameterNamed("missedInstallmentPenaltyType"));
|
||||
}
|
||||
if (command.parameterExists("missedInstallmentPenaltyAmount")) {
|
||||
recurringDepositProduct
|
||||
.setMissedInstallmentPenaltyAmount(command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount"));
|
||||
}
|
||||
recurringDepositProduct.setMaturityInstructions(command.booleanPrimitiveValueOfParameterNamed("maturityInstructions"));
|
||||
recurringDepositProduct.setCreditMaturityProceeds(command.booleanPrimitiveValueOfParameterNamed("creditMaturityProceeds"));
|
||||
recurringDepositProduct.setPenaltyGracePeriodMissedInstallments(
|
||||
|
||||
+5
@@ -70,6 +70,11 @@ public class DepositProductTermAndPreClosure extends AbstractPersistableCustom<L
|
||||
actualChanges.putAll(this.preClosureDetail.update(command, baseDataValidator));
|
||||
}
|
||||
|
||||
// A product created without any deposit term has no embedded term detail at all (every column null), so an
|
||||
// edit that sets one - e.g. a minimum term - would otherwise be dropped silently.
|
||||
if (this.depositTermDetail == null && (command.hasParameter("minDepositTerm") || command.hasParameter("maxDepositTerm"))) {
|
||||
this.depositTermDetail = DepositTermDetail.createFrom(null, null, null, null, null, null);
|
||||
}
|
||||
if (this.depositTermDetail != null) {
|
||||
actualChanges.putAll(this.depositTermDetail.update(command, baseDataValidator));
|
||||
}
|
||||
|
||||
+20
@@ -289,6 +289,22 @@ public final class ProductPendingEditFieldMapper {
|
||||
f.setLienMarked(command.booleanObjectValueOfParameterNamed("lienMarked"));
|
||||
present.add("lienMarked");
|
||||
}
|
||||
if (command.hasParameter("lienPercentage")) {
|
||||
f.setLienPercentage(command.bigDecimalValueOfParameterNamed("lienPercentage"));
|
||||
present.add("lienPercentage");
|
||||
}
|
||||
if (command.hasParameter("missedInstallmentGraceDays")) {
|
||||
f.setMissedInstallmentGraceDays(command.integerValueOfParameterNamed("missedInstallmentGraceDays"));
|
||||
present.add("missedInstallmentGraceDays");
|
||||
}
|
||||
if (command.hasParameter("missedInstallmentPenaltyType")) {
|
||||
f.setMissedInstallmentPenaltyType(command.integerValueOfParameterNamed("missedInstallmentPenaltyType"));
|
||||
present.add("missedInstallmentPenaltyType");
|
||||
}
|
||||
if (command.hasParameter("missedInstallmentPenaltyAmount")) {
|
||||
f.setMissedInstallmentPenaltyAmount(command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount"));
|
||||
present.add("missedInstallmentPenaltyAmount");
|
||||
}
|
||||
if (command.hasParameter("maturityInstructions")) {
|
||||
f.setMaturityInstructions(command.booleanObjectValueOfParameterNamed("maturityInstructions"));
|
||||
present.add("maturityInstructions");
|
||||
@@ -394,6 +410,10 @@ public final class ProductPendingEditFieldMapper {
|
||||
putBoolean(root, submitted, "prematureWithdrawalOption", f.getPrematureWithdrawalOption());
|
||||
putBoolean(root, submitted, "tenureFlexibility", f.getTenureFlexibility());
|
||||
putBoolean(root, submitted, "lienMarked", f.getLienMarked());
|
||||
putNumber(root, submitted, "lienPercentage", f.getLienPercentage());
|
||||
putNumber(root, submitted, "missedInstallmentGraceDays", f.getMissedInstallmentGraceDays());
|
||||
putNumber(root, submitted, "missedInstallmentPenaltyType", f.getMissedInstallmentPenaltyType());
|
||||
putNumber(root, submitted, "missedInstallmentPenaltyAmount", f.getMissedInstallmentPenaltyAmount());
|
||||
putBoolean(root, submitted, "maturityInstructions", f.getMaturityInstructions());
|
||||
putBoolean(root, submitted, "creditMaturityProceeds", f.getCreditMaturityProceeds());
|
||||
putBoolean(root, submitted, "penaltyGracePeriodMissedInstallments", f.getPenaltyGracePeriodMissedInstallments());
|
||||
|
||||
+34
-1
@@ -1143,7 +1143,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
}
|
||||
|
||||
this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(),
|
||||
isTransferInterestToOtherAccount());
|
||||
isTransferInterestToOtherAccount(), isSimpleInterest());
|
||||
|
||||
this.summary.updateFromInterestPeriodSummaries(this.currency, allPostingPeriods);
|
||||
}
|
||||
@@ -3876,6 +3876,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Whether earned interest must never compound. Only a recurring deposit opts in, from its product, when opened. */
|
||||
protected boolean isSimpleInterest() {
|
||||
return this.simpleInterest;
|
||||
}
|
||||
|
||||
public void setSimpleInterest(final boolean simpleInterest) {
|
||||
this.simpleInterest = simpleInterest;
|
||||
}
|
||||
|
||||
public BigDecimal getLienPercentage() {
|
||||
return this.lienPercentage;
|
||||
}
|
||||
|
||||
public void setLienPercentage(final BigDecimal lienPercentage) {
|
||||
this.lienPercentage = lienPercentage;
|
||||
}
|
||||
|
||||
public boolean accountSubmittedAndActivationOnSameDate() {
|
||||
return getActivationDate() != null && DateUtils.isEqual(getActivationDate(), getSubmittedOnDate());
|
||||
|
||||
@@ -4700,6 +4717,22 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom<Long>
|
||||
@Column(name = "auto_debit_facility", nullable = false, columnDefinition = "boolean default false")
|
||||
private Boolean autoDebitFacility = false;
|
||||
|
||||
/**
|
||||
* Taken from the product's Interest Calculation Type when a recurring deposit is opened and kept for the account's
|
||||
* life, like its chart and compounding settings - a later product edit must not change what an open deposit was
|
||||
* promised.
|
||||
*/
|
||||
@Column(name = "is_simple_interest", nullable = false, columnDefinition = "boolean default false")
|
||||
private boolean simpleInterest = false;
|
||||
|
||||
/**
|
||||
* For a recurring deposit under a Lien Marked product: the most of it that may be given as loan, as a percentage
|
||||
* of its balance at the time it is pledged. Set when the deposit is opened (never above the product's percentage);
|
||||
* null falls back to the product's percentage.
|
||||
*/
|
||||
@Column(name = "lien_percentage", scale = 2, precision = 5)
|
||||
private BigDecimal lienPercentage;
|
||||
|
||||
@Column(name = "is_senior_citizen", nullable = false, columnDefinition = "boolean default false")
|
||||
private boolean isSeniorCitizen = false;
|
||||
|
||||
|
||||
+12
@@ -211,6 +211,18 @@ public class SavingsCoreEditFields {
|
||||
@Column(name = "lien_marked")
|
||||
private Boolean lienMarked;
|
||||
|
||||
@Column(name = "lien_percentage")
|
||||
private BigDecimal lienPercentage;
|
||||
|
||||
@Column(name = "missed_installment_grace_days")
|
||||
private Integer missedInstallmentGraceDays;
|
||||
|
||||
@Column(name = "missed_installment_penalty_type")
|
||||
private Integer missedInstallmentPenaltyType;
|
||||
|
||||
@Column(name = "missed_installment_penalty_amount")
|
||||
private BigDecimal missedInstallmentPenaltyAmount;
|
||||
|
||||
@Column(name = "maturity_instructions")
|
||||
private Boolean maturityInstructions;
|
||||
|
||||
|
||||
+42
@@ -319,6 +319,13 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "lien_marked")
|
||||
private boolean lienMarked = false;
|
||||
|
||||
/**
|
||||
* With Lien Marked on: the most a deposit of this product may secure, as a percentage of its balance (e.g. 90 =
|
||||
* a loan of up to 90% of what is in the deposit). Null means the whole unpledged balance.
|
||||
*/
|
||||
@Column(name = "lien_percentage", scale = 2, precision = 5)
|
||||
private BigDecimal lienPercentage;
|
||||
|
||||
@Column(name = "maturity_instructions")
|
||||
private boolean maturityInstructions = false;
|
||||
|
||||
@@ -328,6 +335,17 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
@Column(name = "penalty_grace_period_missed_installments")
|
||||
private boolean penaltyGracePeriodMissedInstallments = false;
|
||||
|
||||
/** Days after an installment's due date before a missed installment is penalised (0 = the next day). */
|
||||
@Column(name = "missed_installment_grace_days")
|
||||
private Integer missedInstallmentGraceDays;
|
||||
|
||||
/** 1 = flat amount per missed installment, 2 = percentage of the installment. */
|
||||
@Column(name = "missed_installment_penalty_type")
|
||||
private Integer missedInstallmentPenaltyType;
|
||||
|
||||
@Column(name = "missed_installment_penalty_amount", scale = 6, precision = 19)
|
||||
private BigDecimal missedInstallmentPenaltyAmount;
|
||||
|
||||
@Column(name = "panel_request_applicable")
|
||||
private boolean panelRequestApplicable = false;
|
||||
|
||||
@@ -923,6 +941,10 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
this.lienMarked = command.booleanPrimitiveValueOfParameterNamed("lienMarked");
|
||||
actualChanges.put("lienMarked", this.lienMarked);
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("lienPercentage", this.lienPercentage)) {
|
||||
this.lienPercentage = command.bigDecimalValueOfParameterNamed("lienPercentage");
|
||||
actualChanges.put("lienPercentage", this.lienPercentage);
|
||||
}
|
||||
if (command.isChangeInBooleanParameterNamed("maturityInstructions", this.maturityInstructions)) {
|
||||
this.maturityInstructions = command.booleanPrimitiveValueOfParameterNamed("maturityInstructions");
|
||||
actualChanges.put("maturityInstructions", this.maturityInstructions);
|
||||
@@ -935,6 +957,18 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
this.penaltyGracePeriodMissedInstallments = command.booleanPrimitiveValueOfParameterNamed("penaltyGracePeriodMissedInstallments");
|
||||
actualChanges.put("penaltyGracePeriodMissedInstallments", this.penaltyGracePeriodMissedInstallments);
|
||||
}
|
||||
if (command.isChangeInIntegerParameterNamed("missedInstallmentGraceDays", this.missedInstallmentGraceDays)) {
|
||||
this.missedInstallmentGraceDays = command.integerValueOfParameterNamed("missedInstallmentGraceDays");
|
||||
actualChanges.put("missedInstallmentGraceDays", this.missedInstallmentGraceDays);
|
||||
}
|
||||
if (command.isChangeInIntegerParameterNamed("missedInstallmentPenaltyType", this.missedInstallmentPenaltyType)) {
|
||||
this.missedInstallmentPenaltyType = command.integerValueOfParameterNamed("missedInstallmentPenaltyType");
|
||||
actualChanges.put("missedInstallmentPenaltyType", this.missedInstallmentPenaltyType);
|
||||
}
|
||||
if (command.isChangeInBigDecimalParameterNamed("missedInstallmentPenaltyAmount", this.missedInstallmentPenaltyAmount)) {
|
||||
this.missedInstallmentPenaltyAmount = command.bigDecimalValueOfParameterNamed("missedInstallmentPenaltyAmount");
|
||||
actualChanges.put("missedInstallmentPenaltyAmount", this.missedInstallmentPenaltyAmount);
|
||||
}
|
||||
if (command.isChangeInBooleanParameterNamed("panelRequestApplicable", this.panelRequestApplicable)) {
|
||||
this.panelRequestApplicable = command.booleanPrimitiveValueOfParameterNamed("panelRequestApplicable");
|
||||
actualChanges.put("panelRequestApplicable", this.panelRequestApplicable);
|
||||
@@ -1300,6 +1334,8 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
|
||||
public boolean isLienMarked() { return lienMarked; }
|
||||
public void setLienMarked(boolean v) { this.lienMarked = v; }
|
||||
public BigDecimal getLienPercentage() { return lienPercentage; }
|
||||
public void setLienPercentage(BigDecimal v) { this.lienPercentage = v; }
|
||||
|
||||
public boolean isMaturityInstructions() { return maturityInstructions; }
|
||||
public void setMaturityInstructions(boolean v) { this.maturityInstructions = v; }
|
||||
@@ -1309,6 +1345,12 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
|
||||
|
||||
public boolean isPenaltyGracePeriodMissedInstallments() { return penaltyGracePeriodMissedInstallments; }
|
||||
public void setPenaltyGracePeriodMissedInstallments(boolean v) { this.penaltyGracePeriodMissedInstallments = v; }
|
||||
public Integer getMissedInstallmentGraceDays() { return missedInstallmentGraceDays; }
|
||||
public void setMissedInstallmentGraceDays(Integer v) { this.missedInstallmentGraceDays = v; }
|
||||
public Integer getMissedInstallmentPenaltyType() { return missedInstallmentPenaltyType; }
|
||||
public void setMissedInstallmentPenaltyType(Integer v) { this.missedInstallmentPenaltyType = v; }
|
||||
public BigDecimal getMissedInstallmentPenaltyAmount() { return missedInstallmentPenaltyAmount; }
|
||||
public void setMissedInstallmentPenaltyAmount(BigDecimal v) { this.missedInstallmentPenaltyAmount = v; }
|
||||
|
||||
public boolean isPanelRequestApplicable() { return panelRequestApplicable; }
|
||||
public void setPanelRequestApplicable(boolean v) { this.panelRequestApplicable = v; }
|
||||
|
||||
+6
@@ -65,6 +65,9 @@ public interface DepositAccountWritePlatformService {
|
||||
|
||||
CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command);
|
||||
|
||||
/** Tenure Flexibility: lengthens a running recurring deposit's term. */
|
||||
CommandProcessingResult extendRecurringDepositTenure(Long savingsId, JsonCommand command);
|
||||
|
||||
CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command);
|
||||
|
||||
SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);
|
||||
@@ -92,6 +95,9 @@ public interface DepositAccountWritePlatformService {
|
||||
|
||||
void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType);
|
||||
|
||||
/** Credits a running recurring deposit's completed posting periods (the nightly maturity job calls it). */
|
||||
void postRecurringDepositPeriodicInterest(Long recurringDepositId);
|
||||
|
||||
/**
|
||||
* Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if
|
||||
* still within the product's renewal grace window (0 days if none is configured), or from today
|
||||
|
||||
Reference in New Issue
Block a user