From 9df8e924a197d384986a243811a8257932250d28 Mon Sep 17 00:00:00 2001 From: keshav Date: Mon, 12 Oct 2026 10:25:12 +0530 Subject: [PATCH] feat: recurring deposit product rules, lien, auto debit, missed installment penalty, and jobs --- .../service/CommandSourceService.java | 1 + .../service/CommandWrapperBuilder.java | 9 + .../infrastructure/jobs/service/JobName.java | 2 + .../savings/DepositsApiConstants.java | 10 + .../savings/data/SavingsAccountData.java | 14 + .../savings/domain/SavingsHelper.java | 6 + .../interest/CompoundInterestHelper.java | 36 +- .../interest/CompoundInterestValues.java | 5 + .../interest/DailyCompoundingPeriod.java | 12 + .../domain/interest/PostingPeriod.java | 15 +- .../service/AccountingProcessorHelper.java | 31 +- ...ualBasedAccountingProcessorForSavings.java | 2 +- ...ashBasedAccountingProcessorForSavings.java | 2 +- ...ccountMappingWritePlatformServiceImpl.java | 8 +- ...ountTransfersWritePlatformServiceImpl.java | 29 + .../api/BranchDayRegisterApiResource.java | 34 + .../ClerkDisbursementRequestApiResource.java | 30 + ...ritePlatformServiceJpaRepositoryIImpl.java | 9 +- .../api/RbiReserveConfigApiResource.java | 8 +- .../api/RbiReserveReturnApiResource.java | 108 +- .../RecurringDepositAccountsApiResource.java | 5 +- .../RecurringDepositDashboardApiResource.java | 205 ++ .../api/RecurringDepositLienApiResource.java | 80 + .../data/DepositProductDataValidator.java | 36 + .../domain/DepositAccountDomainService.java | 3 + .../DepositAccountDomainServiceJpa.java | 179 +- .../domain/RecurringDepositAccount.java | 2862 +++++++++-------- ...RecurringDepositAccountCommandHandler.java | 43 + ...lyRdMissedInstallmentPenaltyJobConfig.java | 62 + .../ExecuteRdAutoDebitJobConfig.java | 60 + .../ExecuteRdAutoDebitTasklet.java | 42 + ...DepositsAccountMaturityDetailsTasklet.java | 10 + ...eMatureCalculationPlatformServiceImpl.java | 8 + .../DepositAccountReadPlatformService.java | 3 + ...DepositAccountReadPlatformServiceImpl.java | 15 + ...WritePlatformServiceJpaRepositoryImpl.java | 141 +- ...WritePlatformServiceJpaRepositoryImpl.java | 7 + ...DepositProductReadPlatformServiceImpl.java | 8 + ...AccountInterestCalculationServiceImpl.java | 169 +- .../RecurringDepositAutoDebitService.java | 157 + .../service/RecurringDepositLienService.java | 176 + ...epositMissedInstallmentPenaltyService.java | 242 ++ .../service/RecurringDepositProductRules.java | 301 ++ ...ingsAccountInterestPostingServiceImpl.java | 4 +- ...SavingsAccountReadPlatformServiceImpl.java | 10 +- .../savings/starter/SavingsConfiguration.java | 12 +- .../tenant/final-changelog-tenant.xml | 5 + .../0594_recurring_deposit_product_rules.xml | 89 + ...0595_recurring_deposit_lien_percentage.xml | 67 + ...ing_deposit_missed_installment_penalty.xml | 128 + ...ecurring_deposit_product_maker_checker.xml | 88 + ...rring_deposit_extend_tenure_permission.xml | 51 + .../service/CommandSourceServiceTest.java | 27 + .../domain/InterestRateChart.java | 6 +- .../domain/InterestRateChartSlabFields.java | 4 + .../data/RecurringDepositAccountData.java | 7 + .../data/RecurringDepositProductData.java | 24 +- .../DepositAccountTermAndPreClosure.java | 17 + .../domain/DepositProductAssembler.java | 13 + .../DepositProductTermAndPreClosure.java | 5 + .../domain/ProductPendingEditFieldMapper.java | 20 + .../savings/domain/SavingsAccount.java | 35 +- .../savings/domain/SavingsCoreEditFields.java | 12 + .../savings/domain/SavingsProduct.java | 42 + .../DepositAccountWritePlatformService.java | 6 + 65 files changed, 4445 insertions(+), 1412 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositLienApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ExtendTenureRecurringDepositAccountCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/applyrdmissedinstallmentpenalty/ApplyRdMissedInstallmentPenaltyJobConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitJobConfig.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitTasklet.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAutoDebitService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositLienService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositMissedInstallmentPenaltyService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductRules.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_recurring_deposit_product_rules.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_recurring_deposit_lien_percentage.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0596_recurring_deposit_missed_installment_penalty.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_enable_recurring_deposit_product_maker_checker.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0598_recurring_deposit_extend_tenure_permission.xml diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java index 591e46473..fd89de486 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandSourceService.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index 0f2140efd..8da153095 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -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"; diff --git a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java index 93e097cc6..b841d9c06 100644 --- a/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java +++ b/fineract-core/src/main/java/org/apache/fineract/infrastructure/jobs/service/JobName.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index aa88c1989..2b2709469 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index f5c9b2716..6278d2394 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -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, diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java index 0d31101e5..76d3c1db2 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsHelper.java @@ -180,6 +180,12 @@ public final class SavingsHelper { immediateWithdrawalOfInterest); } + public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List allPeriods, + LocalDate accountLockedUntil, Boolean immediateWithdrawalOfInterest, final boolean simpleInterest) { + return COMPOUND_INTEREST_HELPER.calculateInterestForAllPostingPeriods(currency, allPeriods, accountLockedUntil, + immediateWithdrawalOfInterest, simpleInterest); + } + public Collection fetchPostInterestTransactionIds(Long accountId) { return this.accountTransfersReadPlatformService.fetchPostInterestTransactionIds(accountId); } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java index c08405035..c4b356863 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java @@ -40,6 +40,18 @@ public class CompoundInterestHelper { */ public Money calculateInterestForAllPostingPeriods(final MonetaryCurrency currency, final List 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 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; + } + } } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java index 68cfc07cd..c129cfe9d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestValues.java @@ -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; + } + } diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/DailyCompoundingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/DailyCompoundingPeriod.java index c46b5518a..e5e8a2985 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/DailyCompoundingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/DailyCompoundingPeriod.java @@ -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; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java index 80ca8df25..823b006b5 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java @@ -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(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 062bb76a2..15ee85c43 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -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 {} = {}]", diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java index 82d124e58..43fe288fe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccrualBasedAccountingProcessorForSavings.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java index 1ca1ad249..43d8adcaa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/CashBasedAccountingProcessorForSavings.java @@ -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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index ef33e9074..a3f8031fc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -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()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java index 9a34bd589..0ddae7b62 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/account/service/AccountTransfersWritePlatformServiceImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index 82224e25c..4868e14fb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -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 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 ──── /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 00e292a9a..680c0d895 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -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> 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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java index 9773a0e57..7393da7fa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanaccount/guarantor/service/GuarantorWritePlatformServiceJpaRepositoryIImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java index afa3a01a9..7d920a3b7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveConfigApiResource.java @@ -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."); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java index 2009a31a8..5481aaaf6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/rbireserve/api/RbiReserveReturnApiResource.java @@ -1286,16 +1286,22 @@ public class RbiReserveReturnApiResource { final Rate slr) { final List> lines = new ArrayList<>(); - lines.add(formOneHeading("I. LIABILITIES IN INDIA TO THE BANKING SYSTEM")); - lines.add(formOneHeading("(a) Demand Liabilities")); - final List 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 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 iaii = formOneLine(lines, "(ii) Other demand liabilities to the banking system", + final List 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 ib = formOneLine(lines, "(b) Time liabilities to the banking system", "FORM1_I_B_TIME_BS", + final List ib = formOneLine(lines, "(b) Time Liabilities to the banking system", "FORM1_I_B_TIME_BS", dates, officeId, 1); final List 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 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 iiiA = formOneLine(lines, "(A) Current account balances with SBI, its subsidiaries and the corresponding new banks", + final List 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 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 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 iiiBii = formOneLine(lines, "(ii) Money at call and short notice", + final List iiiBii = formOneLine(lines, "(ii) Money at Call and Short notice :", "FORM1_III_B_II_MONEY_AT_CALL_BS", dates, officeId, 2); - final List iiiBiv = formOneLine(lines, "(iv) Advances and any other assets", + final List iiiBiv = formOneLine(lines, "(iv) Advances & any other Assets :", "FORM1_III_B_IV_ADVANCES_BS", dates, officeId, 2); final List 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 ivBase = new ArrayList<>(); final List 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 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 v = formOneLine(lines, "V. CASH ON HAND", "CASH_IN_HAND", dates, officeId, 0); + final List v = formOneLine(lines, "V. CASH ON HAND :", "CASH_IN_HAND", dates, officeId, 0); - lines.add(formOneHeading("VI. BALANCE IN CURRENT ACCOUNTS WITH")); - final List 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 viA = formOneLine(lines, "(a) Reserve Bank of India :", "FORM1_VI_A_RBI_CURRENT", dates, officeId, 1); - final List viB = formOneLine(lines, "(b) State Co-operative Bank of the state concerned", + final List viB = formOneLine(lines, "(b) State Co.Op.Bank of the State Concerned :", "FORM1_VI_B_STATE_COOP_CURRENT", dates, officeId, 1); - final List viC = formOneLine(lines, "(c) District Central Co-operative Bank of the district concerned", + final List viC = formOneLine(lines, "(c) Central Co.Op.Bank of the Dist.Concerned :", "FORM1_VI_C_DCCB_CURRENT", dates, officeId, 1); final List 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 viiA = formOneLine(lines, "(a) State Co-operative Bank of the state concerned", + lines.add(formOneHeading("VII. Balance Of all Other types With :")); + final List viiA = formOneLine(lines, "(a) State Co.Op.Bank of the State Concerned :", "FORM1_VII_A_STATE_COOP_OTHER", dates, officeId, 1); - final List viiB = formOneLine(lines, "(b) District Central Co-operative Bank", + final List viiB = formOneLine(lines, "(B) CENTRAL CO.OP.BANK OF THE DIST.CONCERNED", "FORM1_VII_B_DCCB_OTHER", dates, officeId, 1); - final List viiC = formOneLine(lines, "(c) Investment in Government securities", + final List viiC = formOneLine(lines, "(C) INVESTMENT OF GOVT. SECURITY", "FORM1_VII_C_GOVT_SECURITIES", dates, officeId, 1); final List 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 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 totalXII = sumOf(cashAndOther, gold, securities); - lines.add(formOneTotal("TOTAL OF XII", totalXII)); + lines.add(formOneTotal("Total of XII.....................................", totalXII)); final Map 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountsApiResource.java index a8c66f90d..85777ae6a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositAccountsApiResource.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java new file mode 100644 index 000000000..753d294d2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositDashboardApiResource.java @@ -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. + * + *

+ * 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> rows = this.jdbcTemplate.queryForList(ROWS_SQL, RECURRING_DEPOSIT, ACTIVE, MATURED, hierarchy, + monthStart, monthEnd, DEPOSIT, today, today, today, today, monthStart, monthEnd, monthStart, monthEnd); + final Map closed = this.jdbcTemplate.queryForMap(CLOSED_THIS_MONTH_SQL, CLOSED, PRE_MATURE_CLOSURE, + RECURRING_DEPOSIT, monthStart, monthEnd, hierarchy); + final Map penalty = this.jdbcTemplate.queryForMap(PENALTY_INCOME_SQL, monthStart, monthEnd, hierarchy); + + final Map 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositLienApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositLienApiResource.java new file mode 100644 index 000000000..0f1a2dd16 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/RecurringDepositLienApiResource.java @@ -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 liens = this.recurringDepositLienService.activeLiens(accountId); + final Map 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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductDataValidator.java index ba935bf0f..a91bab872 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductDataValidator.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java index 82e640fb2..2690ddeb2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainService.java @@ -67,4 +67,7 @@ public interface DepositAccountDomainService { Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command, Map changes); + + /** Names the journal entries of the recurring deposit's interest credits that have no narration yet. */ + void narrateRdInterestPostings(RecurringDepositAccount account); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index c6ba74a3f..885b3d6fe 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -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 existingTransactionIds, Set 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. + * + *

+ * 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 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"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java index 62cb85a65..f70300ccd 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/RecurringDepositAccount.java @@ -1,1293 +1,1569 @@ -/** - * 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.domain; - -import static org.apache.fineract.portfolio.savings.DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME; -import static org.apache.fineract.portfolio.savings.DepositsApiConstants.depositPeriodParamName; -import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermParamName; -import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; - -import jakarta.persistence.CascadeType; -import jakarta.persistence.DiscriminatorValue; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.OneToMany; -import jakarta.persistence.OneToOne; -import jakarta.persistence.OrderBy; -import java.math.BigDecimal; -import java.math.MathContext; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.Comparator; -import java.util.List; -import java.util.Locale; -import java.util.Map; -import java.util.Set; -import org.apache.fineract.infrastructure.core.api.JsonCommand; -import org.apache.fineract.infrastructure.core.data.ApiParameterError; -import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; -import org.apache.fineract.infrastructure.core.domain.ExternalId; -import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; -import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; -import org.apache.fineract.infrastructure.core.service.DateUtils; -import org.apache.fineract.organisation.monetary.domain.Money; -import org.apache.fineract.organisation.staff.domain.Staff; -import org.apache.fineract.portfolio.accountdetails.domain.AccountType; -import org.apache.fineract.portfolio.calendar.domain.Calendar; -import org.apache.fineract.portfolio.calendar.service.CalendarUtils; -import org.apache.fineract.portfolio.client.domain.Client; -import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; -import org.apache.fineract.portfolio.group.domain.Group; -import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; -import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; -import org.apache.fineract.portfolio.savings.DepositAccountType; -import org.apache.fineract.portfolio.savings.DepositAccountUtils; -import org.apache.fineract.portfolio.savings.DepositsApiConstants; -import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType; -import org.apache.fineract.portfolio.savings.SavingsApiConstants; -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.SavingsPeriodFrequencyType; -import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; -import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; -import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; -import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; -import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; -import org.apache.fineract.useradministration.domain.AppUser; - -@Entity -@DiscriminatorValue("300") -public class RecurringDepositAccount extends SavingsAccount { - - @OneToOne(mappedBy = "account", cascade = CascadeType.ALL) - private DepositAccountTermAndPreClosure accountTermAndPreClosure; - - @OneToOne(mappedBy = "account", cascade = CascadeType.ALL) - private DepositAccountRecurringDetail recurringDetail; - - @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "account") - private DepositAccountInterestRateChart chart; - - @OrderBy(value = "installmentNumber, id") - @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) - private List depositScheduleInstallments = new ArrayList<>(); - - protected RecurringDepositAccount() { - // - } - - public static RecurringDepositAccount createNewApplicationForSubmittal(final Client client, final Group group, - final SavingsProduct product, final Staff fieldOfficer, final String accountNo, final ExternalId externalId, - final AccountType accountType, final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, - final DepositAccountInterestRateChart chart, final boolean withHoldTax) { - - final boolean allowOverdraft = false; - final BigDecimal overdraftLimit = new BigDecimal(0); - - final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; - return new RecurringDepositAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, - submittedOnDate, submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, - interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, - lockinPeriodFrequencyType, withdrawalFeeApplicableForTransfer, savingsAccountCharges, accountTermAndPreClosure, - recurringDetail, chart, allowOverdraft, overdraftLimit, withHoldTax); - } - - public static RecurringDepositAccount createNewActivatedAccount(final Client client, final Group group, final SavingsProduct product, - final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, - final DepositAccountInterestRateChart chart, final boolean withHoldTax) { - - final boolean allowOverdraft = false; - final BigDecimal overdraftLimit = new BigDecimal(0); - - final SavingsAccountStatusType status = SavingsAccountStatusType.ACTIVE; - return new RecurringDepositAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, - submittedOnDate, submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, - interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, - lockinPeriodFrequencyType, withdrawalFeeApplicableForTransfer, savingsAccountCharges, accountTermAndPreClosure, - recurringDetail, chart, allowOverdraft, overdraftLimit, withHoldTax); - } - - private RecurringDepositAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, - final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, - final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, - final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, - final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, - final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, - final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, - final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, - final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, - final DepositAccountInterestRateChart chart, final boolean allowOverdraft, final BigDecimal overdraftLimit, - final boolean withHoldTax) { - - super(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, - nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, - interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, withHoldTax); - - this.accountTermAndPreClosure = accountTermAndPreClosure; - this.recurringDetail = recurringDetail; - this.chart = chart; - if (this.chart != null) { - this.chart.updateDepositAccountReference(this); - } - } - - @Override - public void modifyApplication(final JsonCommand command, final Map actualChanges) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); - super.modifyApplication(command, actualChanges, baseDataValidator); - final Map termAndPreClosureChanges = accountTermAndPreClosure.update(command, baseDataValidator); - actualChanges.putAll(termAndPreClosureChanges); - recurringDetail.update(command); - validateDomainRules(baseDataValidator); - super.validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - private void updateDepositAmount() { - BigDecimal recurringAmount = getRecurringDetail().mandatoryRecommendedDepositAmount(); - Integer numberOfDepositPeriods = depositScheduleInstallments().size(); - if (this.accountTermAndPreClosure.depositPeriod() != null && recurringAmount != null && numberOfDepositPeriods != null) { - BigDecimal depositAmount = Money.of(product.currency(), recurringAmount).multipliedBy(numberOfDepositPeriods) - .plus(this.minRequiredOpeningBalance).getAmount(); - accountTermAndPreClosure.updateDepositAmount(depositAmount); - } else if (accountTermAndPreClosure.depositAmount() == null) { - accountTermAndPreClosure.updateDepositAmount(Money.zero(product.currency()).getAmount()); - } - } - - public void updateDepositAmount(final BigDecimal depositAmount) { - if (this.accountTermAndPreClosure.depositPeriod() == null) { - accountTermAndPreClosure.updateDepositAmount(accountTermAndPreClosure.depositAmount().add(depositAmount)); - } - } - - @Override - protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) { - boolean isPreMatureClosure = false; - return getEffectiveInterestRateAsFraction(mc, interestPostingUpToDate, isPreMatureClosure); - } - - protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate, - final boolean isPreMatureClosure) { - - boolean applyPreMaturePenalty = false; - BigDecimal penalInterest = BigDecimal.ZERO; - LocalDate depositCloseDate = calculateMaturityDate(); - if (isPreMatureClosure) { - if (this.accountTermAndPreClosure.isPreClosurePenalApplicable()) { - applyPreMaturePenalty = true; - penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest(); - final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure.depositPreClosureDetail() - .preClosurePenalInterestOnType(); - if (preClosurePenalInterestOnType.isWholeTerm()) { - depositCloseDate = interestCalculatedUpto(); - } else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) { - depositCloseDate = interestPostingUpToDate; - } - } - } - - if (depositCloseDate == null) { - depositCloseDate = DateUtils.getBusinessLocalDate(); - } - - final BigDecimal depositAmount = accountTermAndPreClosure.depositAmount(); - BigDecimal applicableInterestRate = BigDecimal.ZERO; - if (this.chart != null) { - applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client); - - // CBS: override with senior citizen rate if applicable - if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null - && getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) { - applicableInterestRate = getSeniorCitizenInterestRate(); - } - - if (applyPreMaturePenalty) { - applicableInterestRate = applicableInterestRate.subtract(penalInterest); - applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate; - } - } - - this.nominalAnnualInterestRate = applicableInterestRate; - - return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc); - } - - @Override - public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { - // CBS: accrual must also use senior citizen rate to stay consistent with maturity calculation - if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null - && getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) { - return getSeniorCitizenInterestRate().divide(BigDecimal.valueOf(100L), mc); - } - return super.getEffectiveInterestRateAsFractionAccrual(mc, upToInterestCalculationDate); - } - - public void updateMaturityDateAndAmount(final MathContext mc, final boolean isPreMatureClosure, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { - final LocalDate maturityDate = calculateMaturityDate(); - LocalDate interestCalculationUpto = null; - List allTransactions = null; - if (maturityDate == null) { - interestCalculationUpto = DateUtils.getBusinessLocalDate(); - allTransactions = getTransactions(interestCalculationUpto, false); - } else { - interestCalculationUpto = maturityDate.minusDays(1); - allTransactions = getTransactions(interestCalculationUpto, true); - - } - - final List postingPeriods = calculateInterestPayable(mc, interestCalculationUpto, allTransactions, - isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - Money totalInterestPayable = Money.zero(getCurrency()); - Money totalDepositAmount = Money.zero(getCurrency()); - for (PostingPeriod postingPeriod : postingPeriods) { - totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned()); - totalDepositAmount = totalDepositAmount.plus(postingPeriod.closingBalance()).minus(postingPeriod.openingBalance()); - } - if (maturityDate == null) { - this.accountTermAndPreClosure.updateDepositAmount(totalDepositAmount.getAmount()); - } else { - this.accountTermAndPreClosure.updateMaturityDetails(totalDepositAmount.getAmount(), totalInterestPayable.getAmount(), - maturityDate); - } - } - - public void updateMaturityStatus(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final boolean postReversals) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.updateMaturityDetailsAction); - final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final LocalDate todayDate = DateUtils.getBusinessLocalDate(); - if (!DateUtils.isAfter(this.maturityDate(), todayDate)) { - // update account status - this.status = SavingsAccountStatusType.MATURED.getValue(); - postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, todayDate, postReversals); - } - } - - public LocalDate calculateMaturityDate() { - - final LocalDate startDate = depositStartDate(); - LocalDate maturityDate = null; - final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); - if (depositPeriod == null) { - return maturityDate; - } - switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) { - case DAYS: - maturityDate = startDate.plusDays(depositPeriod); - break; - case WEEKS: - maturityDate = startDate.plusWeeks(depositPeriod); - break; - case MONTHS: - maturityDate = startDate.plusMonths(depositPeriod); - break; - case YEARS: - maturityDate = startDate.plusYears(depositPeriod); - break; - case INVALID: - break; - } - - return maturityDate; - } - - private List calculateInterestPayable(final MathContext mc, final LocalDate maturityDate, - final List transactions, final boolean isPreMatureClosure, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { - - // 1. default to calculate interest based on entire history OR - // 2. determine latest 'posting period' and find interest credited to - // that period - - // A generate list of EndOfDayBalances (not including interest postings) - final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType); - - final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(this.interestCompoundingPeriodType); - - final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType - .fromInt(this.interestCalculationDaysInYearType); - List PostedAsOnDates = getManualPostingDates(); - final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(depositStartDate(), - maturityDate, postingPeriodType, financialYearBeginningMonth, PostedAsOnDates); - - final List allPostingPeriods = new ArrayList<>(); - - Money periodStartingBalance = Money.zero(currency); - - final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); - final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure); - final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); - boolean isInterestTransfer = false; - final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); - List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( - transactions); - for (final LocalDateInterval periodInterval : postingPeriodIntervals) { - boolean isUserPosting = false; - if (PostedAsOnDates.contains(periodInterval.endDate())) { - isUserPosting = true; - } - final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, - savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType, interestCalculationType, - interestRateAsFraction, daysInYearType.getValue(), maturityDate, interestPostTransactions, isInterestTransfer, - minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, - financialYearBeginningMonth); - - periodStartingBalance = postingPeriod.closingBalance(); - - allPostingPeriods.add(postingPeriod); - } - - this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, this.getLockedInUntilDate(), - isTransferInterestToOtherAccount()); - // this.summary.updateFromInterestPeriodSummaries(this.currency, - // allPostingPeriods); - return allPostingPeriods; - } - - private List getTransactions(final LocalDate depositEndDate, final boolean generateFutureTransactions) { - List allTransactions = new ArrayList<>(); - // add existing transactions - allTransactions.addAll(retreiveOrderedNonInterestPostingTransactions()); - LocalDate latestTransactionDate = null; - for (final SavingsAccountTransaction installment : allTransactions) { - if (latestTransactionDate == null || DateUtils.isBefore(latestTransactionDate, installment.getTransactionDate())) { - latestTransactionDate = installment.getTransactionDate(); - } - } - String refNo = null; - if (generateFutureTransactions) { - for (RecurringDepositScheduleInstallment installment : depositScheduleInstallments()) { - if (installment.isPrincipalNotCompleted(getCurrency())) { - LocalDate dueDate = installment.dueDate(); - if (DateUtils.isBefore(dueDate, latestTransactionDate)) { - dueDate = latestTransactionDate; - } - final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(null, office(), null, dueDate, - installment.getDepositAmountOutstanding(getCurrency()), refNo); - allTransactions.add(transaction); - } - } - } - - allTransactions = sortTransactions(allTransactions); - Money runningBalance = Money.zero(getCurrency()); - for (final SavingsAccountTransaction transaction : allTransactions) { - if (transaction.isReversed()) { - transaction.zeroBalanceFields(); - } else { - Money transactionAmount = Money.zero(this.currency); - if (transaction.isCredit()) { - transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); - } else if (transaction.isDebit()) { - transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); - } - - runningBalance = runningBalance.plus(transactionAmount); - transaction.setRunningBalance(runningBalance); - } - } - // loop over transactions in reverse - LocalDate endOfBalanceDate = depositEndDate; - for (int i = allTransactions.size() - 1; i >= 0; i--) { - final SavingsAccountTransaction transaction = allTransactions.get(i); - if (transaction.isNotReversed() && !transaction.isInterestPostingAndNotReversed()) { - transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); - endOfBalanceDate = transaction.getTransactionDate().minusDays(1); - } - } - return allTransactions; - } - - public LocalDate depositStartDate() { - final LocalDate depositStartDate = accountTermAndPreClosure.getExpectedFirstDepositOnDate(); - if (depositStartDate == null) { - return accountSubmittedOrActivationDate(); - } - return depositStartDate; - } - - public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction); - - final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); - if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); - - if (DateUtils.isBefore(closedDate, getActivationDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (isAccountLocked(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.lockin.period"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (maturityDate() != null && DateUtils.isAfter(closedDate, maturityDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.before.maturity.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("cannot.be.a.future.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - if (isAccountLocked(calculateMaturityDate())) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("deposit.period.must.be.greater.than.lock.in.period", - "Deposit period must be greater than account lock-in period."); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final List savingsAccountTransactions = retrieveListOfTransactions(); - if (savingsAccountTransactions.size() > 0) { - final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); - if (accountTransaction.isAfter(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.last.transaction.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); - this.status = SavingsAccountStatusType.PRE_MATURE_CLOSURE.getValue(); - - final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); - final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); - - /* - * // withdraw deposit amount before closing the account final Money transactionAmountMoney = - * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = - * SavingsAccountTransaction.withdrawal(this, office(), paymentDetail, closedDate, transactionAmountMoney, new - * Date()); this.transactions.add(withdraw); - */ - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = closedDate; - this.closedBy = currentUser; - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - - } - - @Override - public Money activateWithBalance() { - // Activation never deposits money -- funding happens strictly through a separate deposit - // transaction (posted by a clerk) after the account is already active, regardless of any - // minimum required opening balance the product has configured. - return Money.zero(this.currency); - } - - protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals, - final Long relaxingDaysConfigForPivotDate) { - final Money minRequiredOpeningBalance = Money.of(this.currency, this.minRequiredOpeningBalance); - final boolean backdatedTxnsAllowedTill = false; - String refNo = null; - if (minRequiredOpeningBalance.isGreaterThanZero()) { - final SavingsAccountTransactionDTO transactionDTO = new SavingsAccountTransactionDTO(fmt, getActivationDate(), - minRequiredOpeningBalance.getAmount(), null, null, accountType); - deposit(transactionDTO, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, refNo); - - // update existing transactions so derived balance fields are - // correct. - recalculateDailyBalances(Money.zero(this.currency), DateUtils.getBusinessLocalDate(), backdatedTxnsAllowedTill, postReversals); - } - } - - public void close(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); - - final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); - if (!SavingsAccountStatusType.MATURED.hasStateOf(currentStatus) && this.maturityDate() != null) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.matured.state"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - final Locale locale = command.extractLocale(); - final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); - final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); - - if (DateUtils.isBefore(closedDate, getActivationDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.activation.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (maturityDate() != null && DateUtils.isBefore(closedDate, maturityDate())) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.account.maturity.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - if (DateUtils.isAfterBusinessDate(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("cannot.be.a.future.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - final List savingsAccountTransactions = retrieveListOfTransactions(); - if (savingsAccountTransactions.size() > 0) { - final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); - if (accountTransaction.isAfter(closedDate)) { - baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) - .failWithCode("must.be.after.last.transaction.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - } - - validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); - this.status = SavingsAccountStatusType.CLOSED.getValue(); - - final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); - final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); - - /* - * // withdraw deposit amount before closing the account final Money transactionAmountMoney = - * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = - * SavingsAccountTransaction.withdrawal(this, office(), paymentDetail, closedDate, transactionAmountMoney, new - * Date()); this.transactions.add(withdraw); - */ - - actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); - actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); - actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); - actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); - - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = closedDate; - this.closedBy = currentUser; - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void postMaturityInterest(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate closeDate, final boolean postReversals) { - LocalDate interestPostingUpToDate = maturityDate(); - if (interestPostingUpToDate == null) { - interestPostingUpToDate = closeDate; - } - this.setClosedOnDate(closeDate); - final MathContext mc = MathContext.DECIMAL64; - boolean isInterestTransfer = false; - LocalDate postInterestOnDate = null; - final boolean backdatedTxnsAllowedTill = false; - final List postingPeriods = calculateInterestUsing(mc, interestPostingUpToDate.minusDays(1), isInterestTransfer, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, - postReversals); - - Money interestPostedToDate = Money.zero(this.currency); - - boolean recalucateDailyBalanceDetails = false; - - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - - LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); - interestPostingTransactionDate = DateUtils.isAfter(interestPostingTransactionDate, interestPostingUpToDate) - ? interestPostingUpToDate - : interestPostingTransactionDate; - final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned(); - - interestPostedToDate = interestPostedToDate.plus(interestEarnedToBePostedForPeriod); - - final SavingsAccountTransaction postingTransaction = findInterestPostingTransactionFor(interestPostingTransactionDate); - if (postingTransaction == null) { - final SavingsAccountTransaction newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); - addTransaction(newPostingTransaction); - recalucateDailyBalanceDetails = true; - } else { - final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); - if (correctionRequired) { - postingTransaction.reverse(); - final SavingsAccountTransaction newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), - interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); - addTransaction(newPostingTransaction); - recalucateDailyBalanceDetails = true; - } - } - } - applyWithholdTaxForDepositAccounts(interestPostingUpToDate, recalucateDailyBalanceDetails, backdatedTxnsAllowedTill); - if (recalucateDailyBalanceDetails) { - // update existing transactions so derived balance fields are - // correct. - recalculateDailyBalances(Money.zero(this.currency), interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); - } - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - } - - public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, boolean postReversals) { - - final Money interestPostedToDate = totalInterestPosted(); - // calculate interest before one day of closure date - final LocalDate interestCalculatedToDate = accountCloseDate.minusDays(1); - final Money interestOnMaturity = calculatePreMatureInterest(interestCalculatedToDate, - retreiveOrderedNonInterestPostingTransactions(), isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth); - - boolean recalucateDailyBalance = false; - final boolean backdatedTxnsAllowedTill = false; - - // post remaining interest - final Money remainigInterestToBePosted = interestOnMaturity.minus(interestPostedToDate); - if (!remainigInterestToBePosted.isZero()) { - final boolean postInterestAsOn = false; - final SavingsAccountTransaction newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), - accountCloseDate, remainigInterestToBePosted, postInterestAsOn); - addTransaction(newPostingTransaction); - recalucateDailyBalance = true; - } - - applyWithholdTaxForDepositAccounts(accountCloseDate, recalucateDailyBalance, backdatedTxnsAllowedTill); - if (recalucateDailyBalance) { - // update existing transactions so derived balance fields are - // correct. - recalculateDailyBalances(Money.zero(this.currency), accountCloseDate, backdatedTxnsAllowedTill, postReversals); - } - - this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); - this.accountTermAndPreClosure.updateMaturityDetails(this.getAccountBalance(), accountCloseDate); - } - - public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { - - final Money interestPostedToDate = totalInterestPosted().copy(); - - final Money interestEarnedTillDate = calculatePreMatureInterest(preMatureDate, retreiveOrderedNonInterestPostingTransactions(), - isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - final Money accountBalance = Money.of(getCurrency(), getAccountBalance()); - final Money maturityAmount = accountBalance.minus(interestPostedToDate).plus(interestEarnedTillDate); - - return maturityAmount.getAmount(); - } - - private Money calculatePreMatureInterest(final LocalDate preMatureDate, final List transactions, - final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, - final Integer financialYearBeginningMonth) { - final MathContext mc = MathContext.DECIMAL64; - this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); - try { - final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); - - Money interestOnMaturity = Money.zero(this.currency); - - for (final PostingPeriod interestPostingPeriod : postingPeriods) { - final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); - interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); - } - this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); - return interestOnMaturity; - } finally { - this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); - } - } - - @Override - public void postInterest(final MathContext mc, final LocalDate postingDate, final boolean isInterestTransfer, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postInterestAson, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - final LocalDate interestPostingUpToDate = interestPostingUpToDate(postingDate); - super.postInterest(mc, interestPostingUpToDate, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postInterestAson, backdatedTxnsAllowedTill, postReversals); - } - - @Override - public List calculateInterestUsing(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer, - final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, - final LocalDate postAsInterestOn, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { - final LocalDate interestPostingUpToDate = interestPostingUpToDate(postingDate); - return super.calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postAsInterestOn, backdatedTxnsAllowedTill, postReversals); - } - - private LocalDate interestPostingUpToDate(final LocalDate interestPostingDate) { - LocalDate interestPostingUpToDate = interestPostingDate; - final LocalDate uptoMaturityDate = interestCalculatedUpto(); - if (uptoMaturityDate != null && DateUtils.isBefore(uptoMaturityDate, interestPostingDate)) { - interestPostingUpToDate = uptoMaturityDate; - } - return interestPostingUpToDate; - } - - public LocalDate maturityDate() { - return this.accountTermAndPreClosure.getMaturityDate(); - } - - public BigDecimal maturityAmount() { - return this.accountTermAndPreClosure.maturityAmount(); - } - - private LocalDate interestCalculatedUpto() { - LocalDate uptoMaturityDate = calculateMaturityDate(); - if (uptoMaturityDate != null) { - // interest should not be calculated for maturity day - uptoMaturityDate = uptoMaturityDate.minusDays(1); - } - return uptoMaturityDate; - } - - private Money totalInterestPosted() { - Money interestPostedToDate = Money.zero(this.currency); - List trans = getTransactions(); - for (final SavingsAccountTransaction transaction : trans) { - if (transaction.isInterestPostingAndNotReversed()) { - interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); - } - } - - return interestPostedToDate; - } - - @Override - public Map activate(final AppUser currentUser, final JsonCommand command) { - final Map actualChanges = super.activate(currentUser, command); - - if (accountTermAndPreClosure.isAfterExpectedFirstDepositDate(getActivationDate())) { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); - final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); - final String dateAsString = formatter.format(this.accountTermAndPreClosure.getExpectedFirstDepositOnDate()); - baseDataValidator.reset().parameter(DepositsApiConstants.activatedOnDateParamName).value(dateAsString) - .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.expected.first.deposit.date"); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - return actualChanges; - } - - protected List sortTransactions(final List transactions) { - final List listOfTransactionsSorted = new ArrayList<>(); - listOfTransactionsSorted.addAll(transactions); - - final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); - Collections.sort(listOfTransactionsSorted, transactionComparator); - return listOfTransactionsSorted; - } - - @Override - public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, - final Long relaxingDaysConfigForPivotDate, final String refNo) { - - if (isAccountMatured()) { - final String defaultUserMessage = "Transaction is not allowed. Account is matured."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg.recurring.deposit.account.transaction.account.is.matured", defaultUserMessage, "transactionDate", - transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (!isBeforeMaturityDate(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction is not allowed. Transaction date is on or after account maturity date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg.recurring.deposit.account.transaction.date.is.after.account.maturity.date", defaultUserMessage, - "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - if (isBeforeDepositStartDate(transactionDTO.getTransactionDate())) { - final String defaultUserMessage = "Transaction is not allowed. Transaction date is on or after account activation and deposit start date."; - final ApiParameterError error = ApiParameterError.parameterError( - "error.msg.recurring.deposit.account.transaction.date.is.before.account.activation.or.deposit.date", defaultUserMessage, - "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); - - final List dataValidationErrors = new ArrayList<>(); - dataValidationErrors.add(error); - - throw new PlatformApiDataValidationException(dataValidationErrors); - } - - final SavingsAccountTransaction transaction = super.deposit(transactionDTO, backdatedTxnsAllowedTill, - relaxingDaysConfigForPivotDate, refNo); - - return transaction; - } - - public void handleScheduleInstallments(final SavingsAccountTransaction transaction) { - final LocalDate transactionDate = transaction.getTransactionDate(); - Money transactionAmountUnprocessed = transaction.getAmount(getCurrency()); - - for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { - if (currentInstallment.isNotFullyPaidOff() && transactionAmountUnprocessed.isGreaterThanZero()) { - if (!this.adjustAdvanceTowardsFuturePayments() && DateUtils.isBefore(transactionDate, currentInstallment.dueDate())) { - transactionAmountUnprocessed = Money.zero(getCurrency()); - } - transactionAmountUnprocessed = handleInstallmentTransaction(currentInstallment, transactionAmountUnprocessed, - transactionDate); - } - } - - } - - public void updateScheduleInstallments() { - // reset all installments to process from the beginning - for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { - currentInstallment.resetDerivedFields(); - } - - final List orderedDepositTransactions = retreiveOrderedDepositTransactions(); - for (SavingsAccountTransaction transaction : orderedDepositTransactions) { - handleScheduleInstallments(transaction); - } - } - - public void updateScheduleInstallmentsWithNewRecommendedDepositAmount(BigDecimal newDepositAmount, - LocalDate depositAmountupdatedFromDate) { - // reset all installments to process from the beginning, also update - // deposit amount as necessary - for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { - if (!DateUtils.isAfter(depositAmountupdatedFromDate, currentInstallment.dueDate())) { - currentInstallment.updateDepositAmountAndResetDerivedFields(newDepositAmount); - } else { - currentInstallment.resetDerivedFields(); - } - } - - final List orderedDepositTransactions = retreiveOrderedDepositTransactions(); - for (SavingsAccountTransaction transaction : orderedDepositTransactions) { - handleScheduleInstallments(transaction); - } - } - - private List retreiveOrderedDepositTransactions() { - final List listOfTransactionsSorted = retrieveListOfTransactions(); - - final List orderedDepositTransactions = new ArrayList<>(); - - for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { - if (transaction.isDepositAndNotReversed()) { - orderedDepositTransactions.add(transaction); - } - } - - return orderedDepositTransactions; - } - - /** - * This method is responsible for checking if the current transaction is 'an advance/early payment' based on the - * details passed through. - * - * Default implementation is check transaction date is before installment due date. - */ - protected boolean isTransactionInAdvanceOfInstallment(final int currentInstallmentIndex, - final List installments, final LocalDate transactionDate) { - final RecurringDepositScheduleInstallment currentInstallment = installments.get(currentInstallmentIndex); - return DateUtils.isBefore(transactionDate, currentInstallment.dueDate()); - } - - private Money handleInstallmentTransaction(final RecurringDepositScheduleInstallment currentInstallment, - final Money transactionAmountUnprocessed, final LocalDate transactionDate) { - Money transactionAmountRemaining = transactionAmountUnprocessed; - Money depositAmountPortion = currentInstallment.payInstallment(transactionDate, transactionAmountRemaining); - transactionAmountRemaining = transactionAmountRemaining.minus(depositAmountPortion); - - return transactionAmountRemaining; - - } - - private boolean isAccountMatured() { - return SavingsAccountStatusType.fromInt(status).isMatured(); - } - - private boolean isBeforeMaturityDate(final LocalDate compareDate) { - final LocalDate maturityDate = this.maturityDate(); - return maturityDate == null || DateUtils.isBefore(compareDate, maturityDate); - } - - private boolean isBeforeDepositStartDate(LocalDate compareDate) { - return DateUtils.isBefore(compareDate, depositStartDate()); - } - - public void validateDomainRules() { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); - validateDomainRules(baseDataValidator); - super.validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - } - - private void validateDomainRules(final DataValidatorBuilder baseDataValidator) { - final boolean isMinTermGreaterThanMax = this.accountTermAndPreClosure.depositTermDetail() - .isMinDepositTermGreaterThanMaxDepositTerm(); - // deposit period should be within min and max deposit term - if (isMinTermGreaterThanMax) { - final Integer maxTerm = this.accountTermAndPreClosure.depositTermDetail().maxDepositTerm(); - baseDataValidator.reset().parameter(maxDepositTermParamName).value(maxTerm) - .failWithCodeNoParameterAddedToErrorCode("max.term.lessthan.min.term"); - } - - final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); - if (this.accountTermAndPreClosure.depositTermDetail().maxDepositTerm() != null) { - baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod).notNull(); - } - if (depositPeriod != null) { - final SavingsPeriodFrequencyType depositPeriodFrequencyType = this.accountTermAndPreClosure.depositPeriodFrequencyType(); - final boolean isValidDepositPeriod = this.accountTermAndPreClosure.depositTermDetail() - .isDepositBetweenMinAndMax(depositStartDate(), calculateMaturityDate()); - if (!isValidDepositPeriod) { - baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod) - .failWithCodeNoParameterAddedToErrorCode("deposit.period.not.between.min.and.max.deposit.term"); - } else { - final Integer inMultiplesOf = this.accountTermAndPreClosure.depositTermDetail().inMultiplesOfDepositTerm(); - if (inMultiplesOf != null) { - final boolean isValid = this.accountTermAndPreClosure.depositTermDetail().isValidInMultiplesOfPeriod(depositPeriod, - depositPeriodFrequencyType); - if (!isValid) { - baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod) - .failWithCodeNoParameterAddedToErrorCode("deposit.period.not.multiple.of.term"); - } - } - } - if (isAccountLocked(calculateMaturityDate())) { - baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod).failWithCode( - "deposit.period.must.be.greater.than.lock.in.period", - "Deposit period must be greater than account lock-in period."); - } - - } - if (firstDepositDateBeforeAccountSubmittedOrActivationDate()) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( - "expected.first.deposit.date.must.be.after.account.submitted.or.activation.date"); - } - - // FIXME: Handle this scenario - /* - * //final boolean recurringFrequencyBeforeDepositPeriod = recurringFrequencyBeforeDepositPeriod(); - * - * if (!recurringFrequencyBeforeDepositPeriod) { - * baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( - * "recurring.frequency.not.before.deposit.period"); } - */ - } - - public void validateApplicableInterestRate() { - final List dataValidationErrors = new ArrayList<>(); - final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) - .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); - LocalDate maturityDate = calculateMaturityDate(); - if (this.chart != null) { - final LocalDate chartFromDate = this.chart.getFromDate(); - LocalDate chartEndDate = this.chart.getEndDate(); - chartEndDate = chartEndDate == null ? DateUtils.getBusinessLocalDate() : chartEndDate; - - final LocalDateInterval chartInterval = LocalDateInterval.create(chartFromDate, chartEndDate); - if (!chartInterval.contains(accountSubmittedOrActivationDate())) { - baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.valid.interest.rate.slab.available.for.date.range"); - } - - if (maturityDate == null) { - maturityDate = DateUtils.getBusinessLocalDate(); - } - - final BigDecimal maturityAmount = this.accountTermAndPreClosure.depositAmount(); - BigDecimal applicableInterestRate = this.chart.getApplicableInterestRate(maturityAmount, depositStartDate(), maturityDate, - this.client); - - if (applicableInterestRate.compareTo(BigDecimal.ZERO) == 0) { - baseDataValidator.reset() - .failWithCodeNoParameterAddedToErrorCode("no.applicable.interest.rate.is.found.based.on.amount.and.deposit.period"); - } - - } - // CBS: chart is optional — CBS base interest rate chart is used instead - if (!dataValidationErrors.isEmpty()) { - throw new PlatformApiDataValidationException(dataValidationErrors); - } - /* - * final boolean recurringFrequencyBeforeDepositPeriod = recurringFrequencyBeforeDepositPeriod(); - * - * if (!recurringFrequencyBeforeDepositPeriod) { - * baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( - * "recurring.frequency.not.before.deposit.period"); } - */ - } - - public boolean isReinvestOnClosure() { - return this.accountTermAndPreClosure.isReinvestOnClosure(); - } - - public boolean isTransferToSavingsOnClosure() { - return this.accountTermAndPreClosure.isTransferToSavingsOnClosure(); - } - - public RecurringDepositAccount reInvest(BigDecimal depositAmount) { - - final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount); - final DepositAccountRecurringDetail recurringDetail = this.recurringDetail.copy(); - final SavingsProduct product = this.product; - final InterestRateChart productChart = product.applicableChart(getClosedOnDate()); - final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart); - final String accountNumber = null; - final ExternalId externalId = this.externalId; - final AccountType accountType = AccountType.fromInt(this.accountType); - final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType); - final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType - .fromInt(this.interestCompoundingPeriodType); - final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); - final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType - .fromInt(this.interestCalculationDaysInYearType); - final BigDecimal minRequiredOpeningBalance = depositAmount; - final BigDecimal interestRate = BigDecimal.ZERO; - final Set savingsAccountCharges = null; - final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); - final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; - final boolean withdrawalFeeApplicableForTransfer = false; - - LocalDate now = getClosedOnDate(); - newAccountTermAndPreClosure.updateExpectedFirstDepositDate(now); - - RecurringDepositAccount rdAccount = RecurringDepositAccount.createNewActivatedAccount(client, group, product, savingsOfficer, - accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType, postingPeriodType, - interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, - withdrawalFeeApplicableForTransfer, savingsAccountCharges, newAccountTermAndPreClosure, recurringDetail, newChart, - withHoldTax); - - rdAccount.setDatesFrom(now); - newAccountTermAndPreClosure.updateAccountReference(rdAccount); - recurringDetail.updateAccountReference(rdAccount); - - return rdAccount; - - } - - private boolean firstDepositDateBeforeAccountSubmittedOrActivationDate() { - final LocalDate expectedFirstDepositLocalDate = accountTermAndPreClosure.getExpectedFirstDepositOnDate(); - return expectedFirstDepositLocalDate != null - && DateUtils.isBefore(expectedFirstDepositLocalDate, accountSubmittedOrActivationDate()); - } - - public void setDatesFrom(final LocalDate now) { - this.rejectedOnDate = null; - this.rejectedBy = null; - this.withdrawnOnDate = null; - this.withdrawnBy = null; - this.closedOnDate = null; - this.closedBy = null; - - this.activatedBy = null; - this.lockedInUntilDate = null; - - this.activatedOnDate = now; - } - - public void setClosedOnDate(final LocalDate closedOnDate) { - this.closedOnDate = closedOnDate; - } - - @Override - protected boolean isTransferInterestToOtherAccount() { - return this.accountTermAndPreClosure.isTransferInterestToLinkedAccount(); - } - - public void generateSchedule(final PeriodFrequencyType frequency, final Integer recurringEvery, final Calendar calendar) { - this.depositScheduleInstallments.clear(); - LocalDate installmentDate = null; - if (this.isCalendarInherited()) { - installmentDate = CalendarUtils.getNextScheduleDate(calendar, accountSubmittedOrActivationDate()); - } else { - installmentDate = depositStartDate(); - } - - int installmentNumber = 1; - final LocalDate maturityDate = calcualteScheduleTillDate(frequency, recurringEvery); - final BigDecimal depositAmount = this.recurringDetail.mandatoryRecommendedDepositAmount(); - while (DateUtils.isBefore(installmentDate, maturityDate)) { - final RecurringDepositScheduleInstallment installment = RecurringDepositScheduleInstallment.installment(this, installmentNumber, - installmentDate, depositAmount); - addDepositScheduleInstallment(installment); - installmentDate = DepositAccountUtils.calculateNextDepositDate(installmentDate, frequency, recurringEvery); - installmentNumber += 1; - } - updateDepositAmount(); - } - - private LocalDate calcualteScheduleTillDate(final PeriodFrequencyType frequency, final Integer recurringEvery) { - LocalDate tillDate = calculateMaturityDate(); - if (tillDate == null) { - final LocalDate today = DateUtils.getBusinessLocalDate(); - tillDate = DepositAccountUtils.calculateNextDepositDate(today, frequency, - recurringEvery * (DepositAccountUtils.GENERATE_MINIMUM_NUMBER_OF_FUTURE_INSTALMENTS + 1)); - } - return tillDate; - } - - private List depositScheduleInstallments() { - return this.depositScheduleInstallments; - } - - private void addDepositScheduleInstallment(final RecurringDepositScheduleInstallment installment) { - this.depositScheduleInstallments.add(installment); - } - - public boolean isCalendarInherited() { - return this.recurringDetail.isCalendarInherited(); - } - - public void updateOverduePayments(final LocalDate todayDate) { - LocalDate overdueUptoDate = this.maturityDate(); - if (overdueUptoDate == null || DateUtils.isAfter(overdueUptoDate, todayDate)) { - overdueUptoDate = todayDate; - } - - final List installments = depositScheduleInstallments(); - int noOfOverdueInstallments = 0; - Money totalOverdueAmount = Money.zero(getCurrency()); - for (RecurringDepositScheduleInstallment installment : installments) { - if (installment.isNotFullyPaidOff() && DateUtils.isAfter(overdueUptoDate, installment.dueDate())) { - noOfOverdueInstallments++; - totalOverdueAmount = totalOverdueAmount.plus(installment.getDepositAmountOutstanding(getCurrency())); - } - } - this.recurringDetail.updateOverdueDetails(noOfOverdueInstallments, totalOverdueAmount); - } - - @Override - public boolean allowWithdrawal() { - return this.recurringDetail.allowWithdrawal(); - } - - public boolean adjustAdvanceTowardsFuturePayments() { - return this.recurringDetail.adjustAdvanceTowardsFuturePayments(); - } - - @Override - public boolean isTransactionsAllowed() { - return isActive() || isAccountMatured(); - } - - public DepositAccountRecurringDetail getRecurringDetail() { - return this.recurringDetail; - } - - static class RecurringDepositScheduleInstallmentComparator implements Comparator { - - @Override - public int compare(final RecurringDepositScheduleInstallment o1, final RecurringDepositScheduleInstallment o2) { - return o1.installmentNumber().compareTo(o2.installmentNumber()); - } - } - - @Override - public void loadLazyCollections() { - this.depositScheduleInstallments.size(); - super.loadLazyCollections(); - this.chart.getId(); - } - - public BigDecimal getDepositAmount() { - return this.accountTermAndPreClosure.depositAmount(); - } - - @Override - public DepositAccountType depositAccountType() { - return DepositAccountType.fromInt(300); - } -} +/** + * 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.domain; + +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.depositPeriodParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; + +import jakarta.persistence.CascadeType; +import jakarta.persistence.DiscriminatorValue; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; +import jakarta.persistence.OrderBy; +import jakarta.persistence.Transient; +import java.math.BigDecimal; +import java.math.MathContext; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.domain.LocalDateInterval; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.organisation.monetary.domain.Money; +import org.apache.fineract.organisation.staff.domain.Staff; +import org.apache.fineract.portfolio.accountdetails.domain.AccountType; +import org.apache.fineract.portfolio.calendar.domain.Calendar; +import org.apache.fineract.portfolio.calendar.service.CalendarUtils; +import org.apache.fineract.portfolio.client.domain.Client; +import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.group.domain.Group; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.DepositAccountUtils; +import org.apache.fineract.portfolio.savings.DepositsApiConstants; +import org.apache.fineract.portfolio.savings.PreClosurePenalInterestOnType; +import org.apache.fineract.portfolio.savings.PrematureClosureReasonType; +import org.apache.fineract.portfolio.savings.SavingsApiConstants; +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.SavingsPeriodFrequencyType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +import org.apache.fineract.portfolio.savings.domain.interest.SavingsAccountTransactionDetailsForPostingPeriod; +import org.apache.fineract.portfolio.savings.service.SavingsEnumerations; +import org.apache.fineract.useradministration.domain.AppUser; + +@Entity +@DiscriminatorValue("300") +public class RecurringDepositAccount extends SavingsAccount { + + @OneToOne(mappedBy = "account", cascade = CascadeType.ALL) + private DepositAccountTermAndPreClosure accountTermAndPreClosure; + + @OneToOne(mappedBy = "account", cascade = CascadeType.ALL) + private DepositAccountRecurringDetail recurringDetail; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL, mappedBy = "account") + private DepositAccountInterestRateChart chart; + + @OrderBy(value = "installmentNumber, id") + @OneToMany(cascade = CascadeType.ALL, mappedBy = "account", orphanRemoval = true, fetch = FetchType.LAZY) + private List depositScheduleInstallments = new ArrayList<>(); + + /** + * Set for one premature-closure request (preview or the real closure) from its closure reason - never persisted. + * True when the reason is one the product waives the pre-closure penalty for (nominee / legal-heir closure, + * deceased settlement). + */ + @Transient + private boolean waivePreClosurePenaltyForThisRequest; + + /** Set for one premature closure of a deceased holder's deposit, when the product allows that despite lock-in. */ + @Transient + private boolean lockInWaivedForThisRequest; + + protected RecurringDepositAccount() { + // + } + + /** + * Applies a premature closure's reason for this one request. Only reasons the product has switched on count: + *

    + *
  • Nominee / legal heir closure - needs "No penalty for nominee / legal heir closure"; waives the penalty.
  • + *
  • Deceased settlement - needs "Deceased Settlement"; waives the penalty and lets it close inside lock-in.
  • + *
+ * Any other reason is an ordinary premature closure. A waiver reason the product does not offer is refused + * rather than silently ignored, so staff never believe a penalty was waived when it was not. + */ + public void applyPrematureClosureReason(final PrematureClosureReasonType reason) { + this.waivePreClosurePenaltyForThisRequest = false; + this.lockInWaivedForThisRequest = false; + // The product's "Premature Withdrawal option" decides whether a deposit may be broken before maturity at all. + // A nominee / legal-heir closure or a deceased holder's settlement is a settlement, not the holder's choice, so + // it still goes through when the product offers it (checked below). + final boolean settlement = PrematureClosureReasonType.NOMINEE_LEGAL_HEIR_CLOSURE.equals(reason) + || PrematureClosureReasonType.DECEASED_SETTLEMENT.equals(reason); + if (this.product != null && !this.product.isPrematureWithdrawalOption() && !settlement) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.premature.closure.not.allowed", + "This deposit's product does not allow premature closure. It can be closed on maturity."); + } + if (reason == null) { + return; + } + if (PrematureClosureReasonType.NOMINEE_LEGAL_HEIR_CLOSURE.equals(reason)) { + if (this.product == null || !this.product.isNoPenaltyNomineeHeirClosure()) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.nominee.closure.not.offered", + "This deposit's product does not waive the penalty for nominee / legal heir closures."); + } + this.waivePreClosurePenaltyForThisRequest = true; + } else if (PrematureClosureReasonType.DECEASED_SETTLEMENT.equals(reason)) { + if (this.product == null || !this.product.isDeceasedSettlement()) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.deceased.settlement.not.offered", + "This deposit's product does not offer Deceased Settlement."); + } + this.waivePreClosurePenaltyForThisRequest = true; + this.lockInWaivedForThisRequest = true; + } + } + + public static RecurringDepositAccount createNewApplicationForSubmittal(final Client client, final Group group, + final SavingsProduct product, final Staff fieldOfficer, final String accountNo, final ExternalId externalId, + final AccountType accountType, final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, + final DepositAccountInterestRateChart chart, final boolean withHoldTax) { + + final boolean allowOverdraft = false; + final BigDecimal overdraftLimit = new BigDecimal(0); + + final SavingsAccountStatusType status = SavingsAccountStatusType.SUBMITTED_AND_PENDING_APPROVAL; + return new RecurringDepositAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, + submittedOnDate, submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, + interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, + lockinPeriodFrequencyType, withdrawalFeeApplicableForTransfer, savingsAccountCharges, accountTermAndPreClosure, + recurringDetail, chart, allowOverdraft, overdraftLimit, withHoldTax); + } + + public static RecurringDepositAccount createNewActivatedAccount(final Client client, final Group group, final SavingsProduct product, + final Staff fieldOfficer, final String accountNo, final ExternalId externalId, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal interestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, + final DepositAccountInterestRateChart chart, final boolean withHoldTax) { + + final boolean allowOverdraft = false; + final BigDecimal overdraftLimit = new BigDecimal(0); + + final SavingsAccountStatusType status = SavingsAccountStatusType.ACTIVE; + return new RecurringDepositAccount(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, + submittedOnDate, submittedBy, interestRate, interestCompoundingPeriodType, interestPostingPeriodType, + interestCalculationType, interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, + lockinPeriodFrequencyType, withdrawalFeeApplicableForTransfer, savingsAccountCharges, accountTermAndPreClosure, + recurringDetail, chart, allowOverdraft, overdraftLimit, withHoldTax); + } + + private RecurringDepositAccount(final Client client, final Group group, final SavingsProduct product, final Staff fieldOfficer, + final String accountNo, final ExternalId externalId, final SavingsAccountStatusType status, final AccountType accountType, + final LocalDate submittedOnDate, final AppUser submittedBy, final BigDecimal nominalAnnualInterestRate, + final SavingsCompoundingInterestPeriodType interestCompoundingPeriodType, + final SavingsPostingInterestPeriodType interestPostingPeriodType, final SavingsInterestCalculationType interestCalculationType, + final SavingsInterestCalculationDaysInYearType interestCalculationDaysInYearType, final BigDecimal minRequiredOpeningBalance, + final Integer lockinPeriodFrequency, final SavingsPeriodFrequencyType lockinPeriodFrequencyType, + final boolean withdrawalFeeApplicableForTransfer, final Set savingsAccountCharges, + final DepositAccountTermAndPreClosure accountTermAndPreClosure, final DepositAccountRecurringDetail recurringDetail, + final DepositAccountInterestRateChart chart, final boolean allowOverdraft, final BigDecimal overdraftLimit, + final boolean withHoldTax) { + + super(client, group, product, fieldOfficer, accountNo, externalId, status, accountType, submittedOnDate, submittedBy, + nominalAnnualInterestRate, interestCompoundingPeriodType, interestPostingPeriodType, interestCalculationType, + interestCalculationDaysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, allowOverdraft, overdraftLimit, withHoldTax); + + this.accountTermAndPreClosure = accountTermAndPreClosure; + this.recurringDetail = recurringDetail; + this.chart = chart; + if (this.chart != null) { + this.chart.updateDepositAccountReference(this); + } + } + + @Override + public void modifyApplication(final JsonCommand command, final Map actualChanges) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.modifyApplicationAction); + super.modifyApplication(command, actualChanges, baseDataValidator); + final Map termAndPreClosureChanges = accountTermAndPreClosure.update(command, baseDataValidator); + actualChanges.putAll(termAndPreClosureChanges); + recurringDetail.update(command); + validateDomainRules(baseDataValidator); + super.validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + private void updateDepositAmount() { + BigDecimal recurringAmount = getRecurringDetail().mandatoryRecommendedDepositAmount(); + Integer numberOfDepositPeriods = depositScheduleInstallments().size(); + if (this.accountTermAndPreClosure.depositPeriod() != null && recurringAmount != null && numberOfDepositPeriods != null) { + BigDecimal depositAmount = Money.of(product.currency(), recurringAmount).multipliedBy(numberOfDepositPeriods) + .plus(this.minRequiredOpeningBalance).getAmount(); + accountTermAndPreClosure.updateDepositAmount(depositAmount); + } else if (accountTermAndPreClosure.depositAmount() == null) { + accountTermAndPreClosure.updateDepositAmount(Money.zero(product.currency()).getAmount()); + } + } + + public void updateDepositAmount(final BigDecimal depositAmount) { + if (this.accountTermAndPreClosure.depositPeriod() == null) { + accountTermAndPreClosure.updateDepositAmount(accountTermAndPreClosure.depositAmount().add(depositAmount)); + } + } + + @Override + protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) { + boolean isPreMatureClosure = false; + return getEffectiveInterestRateAsFraction(mc, interestPostingUpToDate, isPreMatureClosure); + } + + protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate, + final boolean isPreMatureClosure) { + + boolean applyPreMaturePenalty = false; + BigDecimal penalInterest = BigDecimal.ZERO; + LocalDate depositCloseDate = calculateMaturityDate(); + if (isPreMatureClosure) { + if (this.accountTermAndPreClosure.isPreClosurePenalApplicable() && !this.waivePreClosurePenaltyForThisRequest) { + applyPreMaturePenalty = true; + penalInterest = this.accountTermAndPreClosure.depositPreClosureDetail().preClosurePenalInterest(); + final PreClosurePenalInterestOnType preClosurePenalInterestOnType = this.accountTermAndPreClosure.depositPreClosureDetail() + .preClosurePenalInterestOnType(); + if (preClosurePenalInterestOnType.isWholeTerm()) { + depositCloseDate = interestCalculatedUpto(); + } else if (preClosurePenalInterestOnType.isTillPrematureWithdrawal()) { + depositCloseDate = interestPostingUpToDate; + } + } + } + + if (depositCloseDate == null) { + depositCloseDate = DateUtils.getBusinessLocalDate(); + } + + final BigDecimal depositAmount = accountTermAndPreClosure.depositAmount(); + BigDecimal applicableInterestRate = BigDecimal.ZERO; + if (this.chart != null) { + applicableInterestRate = this.chart.getApplicableInterestRate(depositAmount, depositStartDate(), depositCloseDate, this.client); + + // CBS: override with senior citizen rate if applicable + if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null + && getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) { + applicableInterestRate = getSeniorCitizenInterestRate(); + } + + if (applyPreMaturePenalty) { + applicableInterestRate = applicableInterestRate.subtract(penalInterest); + applicableInterestRate = applicableInterestRate.compareTo(BigDecimal.ZERO) < 0 ? BigDecimal.ZERO : applicableInterestRate; + } + } + + this.nominalAnnualInterestRate = applicableInterestRate; + + return applicableInterestRate.divide(BigDecimal.valueOf(100L), mc); + } + + @Override + public BigDecimal getEffectiveInterestRateAsFractionAccrual(final MathContext mc, final LocalDate upToInterestCalculationDate) { + // CBS: accrual must also use senior citizen rate to stay consistent with maturity calculation + if (isSeniorCitizen() && getSeniorCitizenInterestRate() != null + && getSeniorCitizenInterestRate().compareTo(BigDecimal.ZERO) > 0) { + return getSeniorCitizenInterestRate().divide(BigDecimal.valueOf(100L), mc); + } + return super.getEffectiveInterestRateAsFractionAccrual(mc, upToInterestCalculationDate); + } + + public void updateMaturityDateAndAmount(final MathContext mc, final boolean isPreMatureClosure, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { + final LocalDate maturityDate = calculateMaturityDate(); + LocalDate interestCalculationUpto = null; + List allTransactions = null; + if (maturityDate == null) { + interestCalculationUpto = DateUtils.getBusinessLocalDate(); + allTransactions = getTransactions(interestCalculationUpto, false); + } else { + interestCalculationUpto = maturityDate.minusDays(1); + allTransactions = getTransactions(interestCalculationUpto, true); + + } + + final List postingPeriods = calculateInterestPayable(mc, interestCalculationUpto, allTransactions, + isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + Money totalInterestPayable = Money.zero(getCurrency()); + Money totalDepositAmount = Money.zero(getCurrency()); + for (PostingPeriod postingPeriod : postingPeriods) { + totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned()); + totalDepositAmount = totalDepositAmount.plus(postingPeriod.closingBalance()).minus(postingPeriod.openingBalance()); + } + if (maturityDate == null) { + this.accountTermAndPreClosure.updateDepositAmount(totalDepositAmount.getAmount()); + } else { + this.accountTermAndPreClosure.updateMaturityDetails(totalDepositAmount.getAmount(), totalInterestPayable.getAmount(), + maturityDate); + } + } + + public void updateMaturityStatus(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final boolean postReversals) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.updateMaturityDetailsAction); + final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final LocalDate todayDate = DateUtils.getBusinessLocalDate(); + if (!DateUtils.isAfter(this.maturityDate(), todayDate)) { + // update account status + this.status = SavingsAccountStatusType.MATURED.getValue(); + postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, todayDate, postReversals); + } + } + + public LocalDate calculateMaturityDate() { + + final LocalDate startDate = depositStartDate(); + LocalDate maturityDate = null; + final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); + if (depositPeriod == null) { + return maturityDate; + } + switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) { + case DAYS: + maturityDate = startDate.plusDays(depositPeriod); + break; + case WEEKS: + maturityDate = startDate.plusWeeks(depositPeriod); + break; + case MONTHS: + maturityDate = startDate.plusMonths(depositPeriod); + break; + case YEARS: + maturityDate = startDate.plusYears(depositPeriod); + break; + case INVALID: + break; + } + + return maturityDate; + } + + private List calculateInterestPayable(final MathContext mc, final LocalDate maturityDate, + final List transactions, final boolean isPreMatureClosure, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { + + // 1. default to calculate interest based on entire history OR + // 2. determine latest 'posting period' and find interest credited to + // that period + + // A generate list of EndOfDayBalances (not including interest postings) + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType); + + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + List PostedAsOnDates = getManualPostingDates(); + final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(depositStartDate(), + maturityDate, postingPeriodType, financialYearBeginningMonth, PostedAsOnDates); + + final List allPostingPeriods = new ArrayList<>(); + + Money periodStartingBalance = Money.zero(currency); + + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); + final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure); + final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); + boolean isInterestTransfer = false; + final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); + List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( + transactions); + for (final LocalDateInterval periodInterval : postingPeriodIntervals) { + boolean isUserPosting = false; + if (PostedAsOnDates.contains(periodInterval.endDate())) { + isUserPosting = true; + } + final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, + savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType, interestCalculationType, + interestRateAsFraction, daysInYearType.getValue(), maturityDate, interestPostTransactions, isInterestTransfer, + minBalanceForInterestCalculation, isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, + financialYearBeginningMonth); + + periodStartingBalance = postingPeriod.closingBalance(); + + allPostingPeriods.add(postingPeriod); + } + + this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, this.getLockedInUntilDate(), + isTransferInterestToOtherAccount(), isSimpleInterest()); + // this.summary.updateFromInterestPeriodSummaries(this.currency, + // allPostingPeriods); + return allPostingPeriods; + } + + private List getTransactions(final LocalDate depositEndDate, final boolean generateFutureTransactions) { + List allTransactions = new ArrayList<>(); + // add existing transactions + allTransactions.addAll(retreiveOrderedNonInterestPostingTransactions()); + LocalDate latestTransactionDate = null; + for (final SavingsAccountTransaction installment : allTransactions) { + if (latestTransactionDate == null || DateUtils.isBefore(latestTransactionDate, installment.getTransactionDate())) { + latestTransactionDate = installment.getTransactionDate(); + } + } + String refNo = null; + if (generateFutureTransactions) { + for (RecurringDepositScheduleInstallment installment : depositScheduleInstallments()) { + if (installment.isPrincipalNotCompleted(getCurrency())) { + LocalDate dueDate = installment.dueDate(); + if (DateUtils.isBefore(dueDate, latestTransactionDate)) { + dueDate = latestTransactionDate; + } + final SavingsAccountTransaction transaction = SavingsAccountTransaction.deposit(null, office(), null, dueDate, + installment.getDepositAmountOutstanding(getCurrency()), refNo); + allTransactions.add(transaction); + } + } + } + + allTransactions = sortTransactions(allTransactions); + Money runningBalance = Money.zero(getCurrency()); + for (final SavingsAccountTransaction transaction : allTransactions) { + if (transaction.isReversed()) { + transaction.zeroBalanceFields(); + } else { + Money transactionAmount = Money.zero(this.currency); + if (transaction.isCredit()) { + transactionAmount = transactionAmount.plus(transaction.getAmount(this.currency)); + } else if (transaction.isDebit()) { + transactionAmount = transactionAmount.minus(transaction.getAmount(this.currency)); + } + + runningBalance = runningBalance.plus(transactionAmount); + transaction.setRunningBalance(runningBalance); + } + } + // loop over transactions in reverse + LocalDate endOfBalanceDate = depositEndDate; + for (int i = allTransactions.size() - 1; i >= 0; i--) { + final SavingsAccountTransaction transaction = allTransactions.get(i); + if (transaction.isNotReversed() && !transaction.isInterestPostingAndNotReversed()) { + transaction.updateCumulativeBalanceAndDates(this.currency, endOfBalanceDate); + endOfBalanceDate = transaction.getTransactionDate().minusDays(1); + } + } + return allTransactions; + } + + public LocalDate depositStartDate() { + final LocalDate depositStartDate = accountTermAndPreClosure.getExpectedFirstDepositOnDate(); + if (depositStartDate == null) { + return accountSubmittedOrActivationDate(); + } + return depositStartDate; + } + + public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction); + + final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); + if (!SavingsAccountStatusType.ACTIVE.hasStateOf(currentStatus)) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.active.state"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + + if (DateUtils.isBefore(closedDate, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (isAccountLocked(closedDate) && !this.lockInWaivedForThisRequest) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.lockin.period"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (maturityDate() != null && DateUtils.isAfter(closedDate, maturityDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.before.maturity.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("cannot.be.a.future.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + if (isAccountLocked(calculateMaturityDate())) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("deposit.period.must.be.greater.than.lock.in.period", + "Deposit period must be greater than account lock-in period."); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (savingsAccountTransactions.size() > 0) { + final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (accountTransaction.isAfter(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.last.transaction.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); + this.status = SavingsAccountStatusType.PRE_MATURE_CLOSURE.getValue(); + + final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); + final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); + // Kept with the account so its statement and audit show why it was broken early (and why no penalty). + this.accountTermAndPreClosure.updatePrematureClosureReason( + PrematureClosureReasonType.fromInt(command.integerValueOfParameterNamed(DepositsApiConstants.prematureClosureReasonIdParamName))); + + /* + * // withdraw deposit amount before closing the account final Money transactionAmountMoney = + * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = + * SavingsAccountTransaction.withdrawal(this, office(), paymentDetail, closedDate, transactionAmountMoney, new + * Date()); this.transactions.add(withdraw); + */ + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = closedDate; + this.closedBy = currentUser; + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + + } + + @Override + public Money activateWithBalance() { + // Activation never deposits money -- funding happens strictly through a separate deposit + // transaction (posted by a clerk) after the account is already active, regardless of any + // minimum required opening balance the product has configured. + return Money.zero(this.currency); + } + + protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals, + final Long relaxingDaysConfigForPivotDate) { + final Money minRequiredOpeningBalance = Money.of(this.currency, this.minRequiredOpeningBalance); + final boolean backdatedTxnsAllowedTill = false; + String refNo = null; + if (minRequiredOpeningBalance.isGreaterThanZero()) { + final SavingsAccountTransactionDTO transactionDTO = new SavingsAccountTransactionDTO(fmt, getActivationDate(), + minRequiredOpeningBalance.getAmount(), null, null, accountType); + deposit(transactionDTO, backdatedTxnsAllowedTill, relaxingDaysConfigForPivotDate, refNo); + + // update existing transactions so derived balance fields are + // correct. + recalculateDailyBalances(Money.zero(this.currency), DateUtils.getBusinessLocalDate(), backdatedTxnsAllowedTill, postReversals); + } + } + + public void close(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); + + final SavingsAccountStatusType currentStatus = SavingsAccountStatusType.fromInt(this.status); + if (!SavingsAccountStatusType.MATURED.hasStateOf(currentStatus) && this.maturityDate() != null) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("not.in.matured.state"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + + if (DateUtils.isBefore(closedDate, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.activation.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (maturityDate() != null && DateUtils.isBefore(closedDate, maturityDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.account.maturity.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + if (DateUtils.isAfterBusinessDate(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("cannot.be.a.future.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (savingsAccountTransactions.size() > 0) { + final SavingsAccountTransaction accountTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (accountTransaction.isAfter(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate) + .failWithCode("must.be.after.last.transaction.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + } + + validateActivityNotBeforeClientOrGroupTransferDate(SavingsEvent.SAVINGS_CLOSE_ACCOUNT, closedDate); + this.status = SavingsAccountStatusType.CLOSED.getValue(); + + final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); + final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + this.accountTermAndPreClosure.updateOnAccountClosureStatus(onClosureType); + + /* + * // withdraw deposit amount before closing the account final Money transactionAmountMoney = + * Money.of(this.currency, this.getAccountBalance()); final SavingsAccountTransaction withdraw = + * SavingsAccountTransaction.withdrawal(this, office(), paymentDetail, closedDate, transactionAmountMoney, new + * Date()); this.transactions.add(withdraw); + */ + + actualChanges.put(SavingsApiConstants.statusParamName, SavingsEnumerations.status(this.status)); + actualChanges.put(SavingsApiConstants.localeParamName, command.locale()); + actualChanges.put(SavingsApiConstants.dateFormatParamName, command.dateFormat()); + actualChanges.put(SavingsApiConstants.closedOnDateParamName, closedDate.format(fmt)); + + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = closedDate; + this.closedBy = currentUser; + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + public void postMaturityInterest(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate closeDate, final boolean postReversals) { + LocalDate interestPostingUpToDate = maturityDate(); + if (interestPostingUpToDate == null) { + interestPostingUpToDate = closeDate; + } + this.setClosedOnDate(closeDate); + final MathContext mc = MathContext.DECIMAL64; + final boolean backdatedTxnsAllowedTill = false; + final LocalDate termEnd = interestPostingUpToDate.minusDays(1); + // Worked out on the deposits alone, never on interest already credited: compounding is modelled inside the + // calculation, and simple interest must not earn on credited interest. This is the same calculation as the + // maturity amount shown when the deposit was opened. + final List postingPeriods = calculateInterestPayable(mc, termEnd, getTransactions(termEnd, false), false, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + // Whatever the posting periods have not credited during the term (the last, unfinished period and any rounding) + // is credited on the maturity date, so a second call is a no-op. + Money interestForTerm = Money.zero(this.currency); + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + interestForTerm = interestForTerm.plus(interestPostingPeriod.getInterestEarned()); + } + Money interestToPost = interestForTerm.minus(totalInterestPosted()); + if (interestToPost.isLessThanZero()) { + // More was credited during the term than the term earns - the tenure was extended into a slab with a lower + // rate after some periods were credited at the old one. Those credits are taken back (each reversed, with + // its journal entry) and the term's interest is credited once, so the deposit gets exactly what it earned. + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isInterestPostingAndNotReversed() && !transaction.isReversalTransaction()) { + transaction.reverse(); + } + } + interestToPost = interestForTerm; + } + + boolean recalucateDailyBalanceDetails = false; + if (interestToPost.isGreaterThanZero()) { + final boolean isUserPosting = false; + addTransaction(SavingsAccountTransaction.interestPosting(this, office(), interestPostingUpToDate, interestToPost, isUserPosting)); + recalucateDailyBalanceDetails = true; + } + applyWithholdTaxForDepositAccounts(interestPostingUpToDate, recalucateDailyBalanceDetails, backdatedTxnsAllowedTill); + // Always: working the periods out rewrote the transactions' running balances without interest in them. + recalculateDailyBalances(Money.zero(this.currency), interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } + + /** + * Credits the interest of every posting period - the product's Interest Posting Period: monthly, quarterly, + * half-yearly, annually - that has ended before today and is not credited yet. Each credit is dated on its + * period's last day. + * + * The interest is worked out exactly as the maturity amount is, on the deposits alone at the term's chart rate: + * with Simple interest every period earns on the principal only; with Compounding the interest already earned is + * carried into the next period's calculation. The period that runs into the maturity date is left for the maturity + * posting, which credits whatever remains. + * + * @return true when anything was credited + */ + public boolean postPeriodicInterest(final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final boolean postReversals) { + final LocalDate termEnd = interestCalculatedUpto(); + if (termEnd == null || !isActive()) { + return false; + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + final MathContext mc = MathContext.DECIMAL64; + final List postingPeriods = calculateInterestPayable(mc, termEnd, getTransactions(termEnd, false), false, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + boolean posted = false; + for (final PostingPeriod postingPeriod : postingPeriods) { + final LocalDate periodEnd = postingPeriod.getPeriodInterval().endDate(); + if (!DateUtils.isBefore(periodEnd, today) || !DateUtils.isBefore(periodEnd, termEnd)) { + continue; + } + final Money interest = postingPeriod.getInterestEarned(); + if (interest.isGreaterThanZero() && findInterestPostingTransactionFor(periodEnd) == null) { + final boolean isUserPosting = false; + addTransaction(SavingsAccountTransaction.interestPosting(this, office(), periodEnd, interest, isUserPosting)); + posted = true; + } + } + // Always: working the periods out rewrote the transactions' running balances without interest in them. + final boolean backdatedTxnsAllowedTill = false; + recalculateDailyBalances(Money.zero(this.currency), today, backdatedTxnsAllowedTill, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + return posted; + } + + /** + * Interest for the days a matured deposit waited before it was paid out. The contracted (chart) rate stops at the + * maturity date; from then until the day the account is actually closed the whole maturity balance earns simple + * interest at the product's post-maturity rate (the RD product's "Nominal Annual Interest", which an RD account + * never uses for its own term, since that always comes from the chart). A product rate of zero, or a deposit closed + * on its maturity date, pays nothing extra - exactly how closures behaved before. + * + * The rate is read off the product at closure, not fixed when the account opened: what an overdue deposit earns is + * the bank's current policy for overdue deposits, not part of the original term. + * + * @return the interest posted, zero when none was due + */ + public Money postOverdueMaturityInterest(final LocalDate closedDate, final boolean postReversals) { + final Money none = Money.zero(this.currency); + final LocalDate maturityDate = maturityDate(); + final BigDecimal overdueRate = this.product == null ? null : this.product.nominalAnnualInterestRate(); + if (maturityDate == null || closedDate == null || !closedDate.isAfter(maturityDate) || overdueRate == null + || overdueRate.signum() <= 0) { + return none; + } + final long overdueDays = ChronoUnit.DAYS.between(maturityDate, closedDate); + final int daysInYear = this.interestCalculationDaysInYearType == null ? 365 : this.interestCalculationDaysInYearType; + final MathContext mc = MathContext.DECIMAL64; + final BigDecimal interest = getAccountBalance().multiply(overdueRate, mc).multiply(BigDecimal.valueOf(overdueDays), mc) + .divide(BigDecimal.valueOf((long) daysInYear * 100), mc); + final Money overdueInterest = Money.of(this.currency, interest); + if (!overdueInterest.isGreaterThanZero()) { + return none; + } + final boolean isUserPosting = false; + addTransaction(SavingsAccountTransaction.interestPosting(this, office(), closedDate, overdueInterest, isUserPosting)); + final boolean backdatedTxnsAllowedTill = false; + recalculateDailyBalances(Money.zero(this.currency), closedDate, backdatedTxnsAllowedTill, postReversals); + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + return overdueInterest; + } + + public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, boolean postReversals) { + + final Money interestPostedToDate = totalInterestPosted(); + // calculate interest before one day of closure date + final LocalDate interestCalculatedToDate = accountCloseDate.minusDays(1); + final Money interestOnMaturity = calculatePreMatureInterest(interestCalculatedToDate, + retreiveOrderedNonInterestPostingTransactions(), isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + + boolean recalucateDailyBalance = false; + final boolean backdatedTxnsAllowedTill = false; + + // post remaining interest + Money remainigInterestToBePosted = interestOnMaturity.minus(interestPostedToDate); + if (remainigInterestToBePosted.isLessThanZero()) { + // The periodic credits were earned at the full term's rate; closing early pays the reduced premature rate, + // which comes to less. Those credits are taken back (each reversed, with its journal entry) and the + // premature interest is credited once instead. + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isInterestPostingAndNotReversed() && !transaction.isReversalTransaction()) { + transaction.reverse(); + } + } + remainigInterestToBePosted = interestOnMaturity; + recalucateDailyBalance = true; + } + if (remainigInterestToBePosted.isGreaterThanZero()) { + final boolean postInterestAsOn = false; + final SavingsAccountTransaction newPostingTransaction = SavingsAccountTransaction.interestPosting(this, office(), + accountCloseDate, remainigInterestToBePosted, postInterestAsOn); + addTransaction(newPostingTransaction); + recalucateDailyBalance = true; + } + + applyWithholdTaxForDepositAccounts(accountCloseDate, recalucateDailyBalance, backdatedTxnsAllowedTill); + if (recalucateDailyBalance) { + // update existing transactions so derived balance fields are + // correct. + recalculateDailyBalances(Money.zero(this.currency), accountCloseDate, backdatedTxnsAllowedTill, postReversals); + } + + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + this.accountTermAndPreClosure.updateMaturityDetails(this.getAccountBalance(), accountCloseDate); + } + + public BigDecimal calculatePreMatureAmount(final LocalDate preMatureDate, final boolean isPreMatureClosure, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { + + final Money interestPostedToDate = totalInterestPosted().copy(); + + final Money interestEarnedTillDate = calculatePreMatureInterest(preMatureDate, retreiveOrderedNonInterestPostingTransactions(), + isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + final Money accountBalance = Money.of(getCurrency(), getAccountBalance()); + final Money maturityAmount = accountBalance.minus(interestPostedToDate).plus(interestEarnedTillDate); + + return maturityAmount.getAmount(); + } + + private Money calculatePreMatureInterest(final LocalDate preMatureDate, final List transactions, + final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + final MathContext mc = MathContext.DECIMAL64; + this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate); + try { + final List postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + Money interestOnMaturity = Money.zero(this.currency); + + for (final PostingPeriod interestPostingPeriod : postingPeriods) { + final Money interestEarnedForPeriod = interestPostingPeriod.getInterestEarned(); + interestOnMaturity = interestOnMaturity.plus(interestEarnedForPeriod); + } + this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods); + return interestOnMaturity; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); + } + } + + @Override + public void postInterest(final MathContext mc, final LocalDate postingDate, final boolean isInterestTransfer, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postInterestAson, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + final LocalDate interestPostingUpToDate = interestPostingUpToDate(postingDate); + super.postInterest(mc, interestPostingUpToDate, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postInterestAson, backdatedTxnsAllowedTill, postReversals); + } + + @Override + public List calculateInterestUsing(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, + final LocalDate postAsInterestOn, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { + final LocalDate interestPostingUpToDate = interestPostingUpToDate(postingDate); + return super.calculateInterestUsing(mc, interestPostingUpToDate, isInterestTransfer, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postAsInterestOn, backdatedTxnsAllowedTill, postReversals); + } + + private LocalDate interestPostingUpToDate(final LocalDate interestPostingDate) { + LocalDate interestPostingUpToDate = interestPostingDate; + final LocalDate uptoMaturityDate = interestCalculatedUpto(); + if (uptoMaturityDate != null && DateUtils.isBefore(uptoMaturityDate, interestPostingDate)) { + interestPostingUpToDate = uptoMaturityDate; + } + return interestPostingUpToDate; + } + + public LocalDate maturityDate() { + return this.accountTermAndPreClosure.getMaturityDate(); + } + + public BigDecimal maturityAmount() { + return this.accountTermAndPreClosure.maturityAmount(); + } + + private LocalDate interestCalculatedUpto() { + LocalDate uptoMaturityDate = calculateMaturityDate(); + if (uptoMaturityDate != null) { + // interest should not be calculated for maturity day + uptoMaturityDate = uptoMaturityDate.minusDays(1); + } + return uptoMaturityDate; + } + + /** + * Refuses a closing date the closure would only reject at the end, after it had already tried to pay the deposit + * out: before maturity the deposit cannot be closed (that is a premature closure), and before its last + * transaction the payout is worked out on a balance the deposit did not hold yet, which surfaced as a bare + * "insufficient account balance" instead of the real reason. + */ + public void validateMaturityClosureDate(final LocalDate closedDate) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME + SavingsApiConstants.closeAction); + if (closedDate != null && maturityDate() != null && DateUtils.isBefore(closedDate, maturityDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate).failWithCode( + "must.be.after.account.maturity.date", + "Closed On date (" + closedDate + ") is before the maturity date (" + maturityDate() + + "). Close it on or after maturity, or use Premature Close."); + } + if (closedDate != null && DateUtils.isAfterBusinessDate(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate).failWithCode( + "cannot.be.a.future.date", "Closed On date (" + closedDate + ") cannot be after the business date."); + } + final List transactions = retrieveListOfTransactions(); + if (closedDate != null && !transactions.isEmpty() && transactions.get(transactions.size() - 1).isAfter(closedDate)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(closedDate).failWithCode( + "must.be.after.last.transaction.date", "Closed On date (" + closedDate + + ") is before the deposit's last transaction. Choose a date on or after it."); + } + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + /** + * The part of the balance that is interest - everything posted to the deposit, capped at what it holds - so a + * closure can pay the interest and the principal out as separate credits. + */ + public BigDecimal getInterestInBalance() { + final BigDecimal balance = getAccountBalance() == null ? BigDecimal.ZERO : getAccountBalance(); + return totalInterestPosted().getAmount().max(BigDecimal.ZERO).min(balance.max(BigDecimal.ZERO)); + } + + private Money totalInterestPosted() { + Money interestPostedToDate = Money.zero(this.currency); + List trans = getTransactions(); + for (final SavingsAccountTransaction transaction : trans) { + if (transaction.isInterestPostingAndNotReversed()) { + interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency)); + } + } + + return interestPostedToDate; + } + + @Override + public Map activate(final AppUser currentUser, final JsonCommand command) { + final Map actualChanges = super.activate(currentUser, command); + + if (accountTermAndPreClosure.isAfterExpectedFirstDepositDate(getActivationDate())) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final DateTimeFormatter formatter = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final String dateAsString = formatter.format(this.accountTermAndPreClosure.getExpectedFirstDepositOnDate()); + baseDataValidator.reset().parameter(DepositsApiConstants.activatedOnDateParamName).value(dateAsString) + .failWithCodeNoParameterAddedToErrorCode("cannot.be.before.expected.first.deposit.date"); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + return actualChanges; + } + + protected List sortTransactions(final List transactions) { + final List listOfTransactionsSorted = new ArrayList<>(); + listOfTransactionsSorted.addAll(transactions); + + final SavingsAccountTransactionComparator transactionComparator = new SavingsAccountTransactionComparator(); + Collections.sort(listOfTransactionsSorted, transactionComparator); + return listOfTransactionsSorted; + } + + @Override + public SavingsAccountTransaction deposit(final SavingsAccountTransactionDTO transactionDTO, final boolean backdatedTxnsAllowedTill, + final Long relaxingDaysConfigForPivotDate, final String refNo) { + + if (isAccountMatured()) { + final String defaultUserMessage = "Transaction is not allowed. Account is matured."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.recurring.deposit.account.transaction.account.is.matured", defaultUserMessage, "transactionDate", + transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (!isBeforeMaturityDate(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction is not allowed. Transaction date is on or after account maturity date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.recurring.deposit.account.transaction.date.is.after.account.maturity.date", defaultUserMessage, + "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + if (isBeforeDepositStartDate(transactionDTO.getTransactionDate())) { + final String defaultUserMessage = "Transaction is not allowed. Transaction date is on or after account activation and deposit start date."; + final ApiParameterError error = ApiParameterError.parameterError( + "error.msg.recurring.deposit.account.transaction.date.is.before.account.activation.or.deposit.date", defaultUserMessage, + "transactionDate", transactionDTO.getTransactionDate().format(transactionDTO.getFormatter())); + + final List dataValidationErrors = new ArrayList<>(); + dataValidationErrors.add(error); + + throw new PlatformApiDataValidationException(dataValidationErrors); + } + + final SavingsAccountTransaction transaction = super.deposit(transactionDTO, backdatedTxnsAllowedTill, + relaxingDaysConfigForPivotDate, refNo); + + return transaction; + } + + public void handleScheduleInstallments(final SavingsAccountTransaction transaction) { + final LocalDate transactionDate = transaction.getTransactionDate(); + Money transactionAmountUnprocessed = transaction.getAmount(getCurrency()); + + for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { + if (currentInstallment.isNotFullyPaidOff() && transactionAmountUnprocessed.isGreaterThanZero()) { + if (!this.adjustAdvanceTowardsFuturePayments() && DateUtils.isBefore(transactionDate, currentInstallment.dueDate())) { + transactionAmountUnprocessed = Money.zero(getCurrency()); + } + transactionAmountUnprocessed = handleInstallmentTransaction(currentInstallment, transactionAmountUnprocessed, + transactionDate); + } + } + + } + + public void updateScheduleInstallments() { + // reset all installments to process from the beginning + for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { + currentInstallment.resetDerivedFields(); + } + + final List orderedDepositTransactions = retreiveOrderedDepositTransactions(); + for (SavingsAccountTransaction transaction : orderedDepositTransactions) { + handleScheduleInstallments(transaction); + } + } + + public void updateScheduleInstallmentsWithNewRecommendedDepositAmount(BigDecimal newDepositAmount, + LocalDate depositAmountupdatedFromDate) { + // reset all installments to process from the beginning, also update + // deposit amount as necessary + for (RecurringDepositScheduleInstallment currentInstallment : depositScheduleInstallments()) { + if (!DateUtils.isAfter(depositAmountupdatedFromDate, currentInstallment.dueDate())) { + currentInstallment.updateDepositAmountAndResetDerivedFields(newDepositAmount); + } else { + currentInstallment.resetDerivedFields(); + } + } + + final List orderedDepositTransactions = retreiveOrderedDepositTransactions(); + for (SavingsAccountTransaction transaction : orderedDepositTransactions) { + handleScheduleInstallments(transaction); + } + } + + private List retreiveOrderedDepositTransactions() { + final List listOfTransactionsSorted = retrieveListOfTransactions(); + + final List orderedDepositTransactions = new ArrayList<>(); + + for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { + if (transaction.isDepositAndNotReversed()) { + orderedDepositTransactions.add(transaction); + } + } + + return orderedDepositTransactions; + } + + /** + * This method is responsible for checking if the current transaction is 'an advance/early payment' based on the + * details passed through. + * + * Default implementation is check transaction date is before installment due date. + */ + protected boolean isTransactionInAdvanceOfInstallment(final int currentInstallmentIndex, + final List installments, final LocalDate transactionDate) { + final RecurringDepositScheduleInstallment currentInstallment = installments.get(currentInstallmentIndex); + return DateUtils.isBefore(transactionDate, currentInstallment.dueDate()); + } + + private Money handleInstallmentTransaction(final RecurringDepositScheduleInstallment currentInstallment, + final Money transactionAmountUnprocessed, final LocalDate transactionDate) { + Money transactionAmountRemaining = transactionAmountUnprocessed; + Money depositAmountPortion = currentInstallment.payInstallment(transactionDate, transactionAmountRemaining); + transactionAmountRemaining = transactionAmountRemaining.minus(depositAmountPortion); + + return transactionAmountRemaining; + + } + + private boolean isAccountMatured() { + return SavingsAccountStatusType.fromInt(status).isMatured(); + } + + private boolean isBeforeMaturityDate(final LocalDate compareDate) { + final LocalDate maturityDate = this.maturityDate(); + return maturityDate == null || DateUtils.isBefore(compareDate, maturityDate); + } + + private boolean isBeforeDepositStartDate(LocalDate compareDate) { + return DateUtils.isBefore(compareDate, depositStartDate()); + } + + public void validateDomainRules() { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); + validateDomainRules(baseDataValidator); + super.validateInterestPostingAndCompoundingPeriodTypes(baseDataValidator); + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + + private void validateDomainRules(final DataValidatorBuilder baseDataValidator) { + final boolean isMinTermGreaterThanMax = this.accountTermAndPreClosure.depositTermDetail() + .isMinDepositTermGreaterThanMaxDepositTerm(); + // deposit period should be within min and max deposit term + if (isMinTermGreaterThanMax) { + final Integer maxTerm = this.accountTermAndPreClosure.depositTermDetail().maxDepositTerm(); + baseDataValidator.reset().parameter(maxDepositTermParamName).value(maxTerm) + .failWithCodeNoParameterAddedToErrorCode("max.term.lessthan.min.term"); + } + + final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); + if (this.accountTermAndPreClosure.depositTermDetail().maxDepositTerm() != null) { + baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod).notNull(); + } + if (depositPeriod != null) { + final SavingsPeriodFrequencyType depositPeriodFrequencyType = this.accountTermAndPreClosure.depositPeriodFrequencyType(); + final boolean isValidDepositPeriod = this.accountTermAndPreClosure.depositTermDetail() + .isDepositBetweenMinAndMax(depositStartDate(), calculateMaturityDate()); + if (!isValidDepositPeriod) { + baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod) + .failWithCodeNoParameterAddedToErrorCode("deposit.period.not.between.min.and.max.deposit.term"); + } else { + final Integer inMultiplesOf = this.accountTermAndPreClosure.depositTermDetail().inMultiplesOfDepositTerm(); + if (inMultiplesOf != null) { + final boolean isValid = this.accountTermAndPreClosure.depositTermDetail().isValidInMultiplesOfPeriod(depositPeriod, + depositPeriodFrequencyType); + if (!isValid) { + baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod) + .failWithCodeNoParameterAddedToErrorCode("deposit.period.not.multiple.of.term"); + } + } + } + if (isAccountLocked(calculateMaturityDate())) { + baseDataValidator.reset().parameter(depositPeriodParamName).value(depositPeriod).failWithCode( + "deposit.period.must.be.greater.than.lock.in.period", + "Deposit period must be greater than account lock-in period."); + } + + } + if (firstDepositDateBeforeAccountSubmittedOrActivationDate()) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( + "expected.first.deposit.date.must.be.after.account.submitted.or.activation.date"); + } + + // FIXME: Handle this scenario + /* + * //final boolean recurringFrequencyBeforeDepositPeriod = recurringFrequencyBeforeDepositPeriod(); + * + * if (!recurringFrequencyBeforeDepositPeriod) { + * baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( + * "recurring.frequency.not.before.deposit.period"); } + */ + } + + public void validateApplicableInterestRate() { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(RECURRING_DEPOSIT_ACCOUNT_RESOURCE_NAME); + LocalDate maturityDate = calculateMaturityDate(); + if (this.chart != null) { + final LocalDate chartFromDate = this.chart.getFromDate(); + LocalDate chartEndDate = this.chart.getEndDate(); + chartEndDate = chartEndDate == null ? DateUtils.getBusinessLocalDate() : chartEndDate; + + final LocalDateInterval chartInterval = LocalDateInterval.create(chartFromDate, chartEndDate); + if (!chartInterval.contains(accountSubmittedOrActivationDate())) { + baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode("no.valid.interest.rate.slab.available.for.date.range"); + } + + if (maturityDate == null) { + maturityDate = DateUtils.getBusinessLocalDate(); + } + + final BigDecimal maturityAmount = this.accountTermAndPreClosure.depositAmount(); + BigDecimal applicableInterestRate = this.chart.getApplicableInterestRate(maturityAmount, depositStartDate(), maturityDate, + this.client); + + if (applicableInterestRate.compareTo(BigDecimal.ZERO) == 0) { + baseDataValidator.reset() + .failWithCodeNoParameterAddedToErrorCode("no.applicable.interest.rate.is.found.based.on.amount.and.deposit.period"); + } + + } + // CBS: chart is optional — CBS base interest rate chart is used instead + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + /* + * final boolean recurringFrequencyBeforeDepositPeriod = recurringFrequencyBeforeDepositPeriod(); + * + * if (!recurringFrequencyBeforeDepositPeriod) { + * baseDataValidator.reset().failWithCodeNoParameterAddedToErrorCode( + * "recurring.frequency.not.before.deposit.period"); } + */ + } + + public boolean isReinvestOnClosure() { + return this.accountTermAndPreClosure.isReinvestOnClosure(); + } + + public boolean isTransferToSavingsOnClosure() { + return this.accountTermAndPreClosure.isTransferToSavingsOnClosure(); + } + + /** The premature-closure penalty taken over from the product (see RecurringDepositProductRules). */ + public void applyProductPreClosurePenalty(final DepositPreClosureDetail productDetail) { + this.accountTermAndPreClosure.applyProductPreClosureDetail(productDetail); + } + + public boolean isMatured() { + return SavingsAccountStatusType.MATURED.getValue().equals(this.status); + } + + /** The maturity instruction chosen when the deposit was opened (DepositAccountOnClosureType value), if any. */ + public Integer getOnAccountClosureId() { + return this.accountTermAndPreClosure.getOnAccountClosureType(); + } + + /** The savings account named for Transfer to Savings when the deposit was opened, if any. */ + public Long getTransferToSavingsAccountId() { + return this.accountTermAndPreClosure.getTransferToSavingsAccountId(); + } + + public RecurringDepositAccount reInvest(BigDecimal depositAmount) { + + final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount); + final DepositAccountRecurringDetail recurringDetail = this.recurringDetail.copy(); + final SavingsProduct product = this.product; + final InterestRateChart productChart = product.applicableChart(getClosedOnDate()); + final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart); + final String accountNumber = null; + final ExternalId externalId = this.externalId; + final AccountType accountType = AccountType.fromInt(this.accountType); + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType); + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + final BigDecimal minRequiredOpeningBalance = depositAmount; + final BigDecimal interestRate = BigDecimal.ZERO; + final Set savingsAccountCharges = null; + final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); + final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; + final boolean withdrawalFeeApplicableForTransfer = false; + + LocalDate now = getClosedOnDate(); + newAccountTermAndPreClosure.updateExpectedFirstDepositDate(now); + + RecurringDepositAccount rdAccount = RecurringDepositAccount.createNewActivatedAccount(client, group, product, savingsOfficer, + accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType, postingPeriodType, + interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, lockinPeriodFrequencyType, + withdrawalFeeApplicableForTransfer, savingsAccountCharges, newAccountTermAndPreClosure, recurringDetail, newChart, + withHoldTax); + + rdAccount.setDatesFrom(now); + // The reinvested term carries on under the terms this deposit was opened on, compounding settings included. + rdAccount.setSimpleInterest(isSimpleInterest()); + rdAccount.setLienPercentage(getLienPercentage()); + newAccountTermAndPreClosure.updateAccountReference(rdAccount); + recurringDetail.updateAccountReference(rdAccount); + + return rdAccount; + + } + + private boolean firstDepositDateBeforeAccountSubmittedOrActivationDate() { + final LocalDate expectedFirstDepositLocalDate = accountTermAndPreClosure.getExpectedFirstDepositOnDate(); + return expectedFirstDepositLocalDate != null + && DateUtils.isBefore(expectedFirstDepositLocalDate, accountSubmittedOrActivationDate()); + } + + public void setDatesFrom(final LocalDate now) { + this.rejectedOnDate = null; + this.rejectedBy = null; + this.withdrawnOnDate = null; + this.withdrawnBy = null; + this.closedOnDate = null; + this.closedBy = null; + + this.activatedBy = null; + this.lockedInUntilDate = null; + + this.activatedOnDate = now; + } + + public void setClosedOnDate(final LocalDate closedOnDate) { + this.closedOnDate = closedOnDate; + } + + @Override + protected boolean isTransferInterestToOtherAccount() { + return this.accountTermAndPreClosure.isTransferInterestToLinkedAccount(); + } + + public void generateSchedule(final PeriodFrequencyType frequency, final Integer recurringEvery, final Calendar calendar) { + this.depositScheduleInstallments.clear(); + LocalDate installmentDate = null; + if (this.isCalendarInherited()) { + installmentDate = CalendarUtils.getNextScheduleDate(calendar, accountSubmittedOrActivationDate()); + } else { + installmentDate = depositStartDate(); + } + + int installmentNumber = 1; + final LocalDate maturityDate = calcualteScheduleTillDate(frequency, recurringEvery); + final BigDecimal depositAmount = this.recurringDetail.mandatoryRecommendedDepositAmount(); + while (DateUtils.isBefore(installmentDate, maturityDate)) { + final RecurringDepositScheduleInstallment installment = RecurringDepositScheduleInstallment.installment(this, installmentNumber, + installmentDate, depositAmount); + addDepositScheduleInstallment(installment); + installmentDate = DepositAccountUtils.calculateNextDepositDate(installmentDate, frequency, recurringEvery); + installmentNumber += 1; + } + updateDepositAmount(); + } + + /** + * Tenure Flexibility: lengthens a running deposit's term to {@code newDepositPeriod} (in the unit it was opened + * with). The installments already on the schedule - paid or not - are kept exactly as they are; new ones are added + * after the last, at the same amount and frequency, up to the new maturity date, so Auto Debit and the + * missed-installment rules carry on into the added months. The rate is then the chart's rate for the new, longer + * term, and the maturity date and amount are worked out again. + * + * @return the changes made, for the audit trail + */ + public Map extendTenure(final Integer newDepositPeriod, final PeriodFrequencyType frequency, final Integer recurringEvery, + final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { + if (this.product == null || !this.product.isTenureFlexibility()) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.tenure.flexibility.not.offered", + "This deposit's product does not allow its tenure to be changed."); + } + if (!isActive()) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.tenure.extend.not.active", + "Only an active recurring deposit's tenure can be extended."); + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate oldMaturityDate = maturityDate(); + if (oldMaturityDate != null && !DateUtils.isAfter(oldMaturityDate, today)) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.tenure.extend.matured", + "This deposit has already reached its maturity date (" + oldMaturityDate + "); its tenure can no longer be extended."); + } + final Integer oldDepositPeriod = this.accountTermAndPreClosure.depositPeriod(); + if (newDepositPeriod == null || oldDepositPeriod == null || newDepositPeriod <= oldDepositPeriod) { + throw new GeneralPlatformDomainRuleException("error.msg.recurringdeposit.tenure.extend.not.longer", + "The new tenure must be longer than the current tenure of " + oldDepositPeriod + ".", oldDepositPeriod); + } + + this.accountTermAndPreClosure.extendDepositPeriod(newDepositPeriod); + final LocalDate newMaturityDate = calculateMaturityDate(); + + RecurringDepositScheduleInstallment last = null; + for (final RecurringDepositScheduleInstallment installment : depositScheduleInstallments()) { + if (last == null || installment.installmentNumber() > last.installmentNumber()) { + last = installment; + } + } + final BigDecimal depositAmount = this.recurringDetail.mandatoryRecommendedDepositAmount(); + int installmentNumber = last == null ? 1 : last.installmentNumber() + 1; + LocalDate installmentDate = last == null ? depositStartDate() + : DepositAccountUtils.calculateNextDepositDate(last.dueDate(), frequency, recurringEvery); + int added = 0; + while (DateUtils.isBefore(installmentDate, newMaturityDate)) { + addDepositScheduleInstallment(RecurringDepositScheduleInstallment.installment(this, installmentNumber, installmentDate, depositAmount)); + installmentDate = DepositAccountUtils.calculateNextDepositDate(installmentDate, frequency, recurringEvery); + installmentNumber++; + added++; + } + updateDepositAmount(); + + // Same checks an application gets: within the product's minimum/maximum term and beyond lock-in. + validateDomainRules(); + final MathContext mc = MathContext.DECIMAL64; + final boolean isPreMatureClosure = false; + updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + validateApplicableInterestRate(); + + final Map changes = new LinkedHashMap<>(); + changes.put("oldDepositPeriod", oldDepositPeriod); + changes.put(DepositsApiConstants.depositPeriodParamName, newDepositPeriod); + changes.put("oldMaturityDate", oldMaturityDate == null ? null : oldMaturityDate.toString()); + changes.put("maturityDate", maturityDate() == null ? null : maturityDate().toString()); + changes.put("maturityAmount", maturityAmount()); + changes.put("installmentsAdded", added); + return changes; + } + + private LocalDate calcualteScheduleTillDate(final PeriodFrequencyType frequency, final Integer recurringEvery) { + LocalDate tillDate = calculateMaturityDate(); + if (tillDate == null) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + tillDate = DepositAccountUtils.calculateNextDepositDate(today, frequency, + recurringEvery * (DepositAccountUtils.GENERATE_MINIMUM_NUMBER_OF_FUTURE_INSTALMENTS + 1)); + } + return tillDate; + } + + private List depositScheduleInstallments() { + return this.depositScheduleInstallments; + } + + private void addDepositScheduleInstallment(final RecurringDepositScheduleInstallment installment) { + this.depositScheduleInstallments.add(installment); + } + + public boolean isCalendarInherited() { + return this.recurringDetail.isCalendarInherited(); + } + + public void updateOverduePayments(final LocalDate todayDate) { + LocalDate overdueUptoDate = this.maturityDate(); + if (overdueUptoDate == null || DateUtils.isAfter(overdueUptoDate, todayDate)) { + overdueUptoDate = todayDate; + } + + final List installments = depositScheduleInstallments(); + int noOfOverdueInstallments = 0; + Money totalOverdueAmount = Money.zero(getCurrency()); + for (RecurringDepositScheduleInstallment installment : installments) { + if (installment.isNotFullyPaidOff() && DateUtils.isAfter(overdueUptoDate, installment.dueDate())) { + noOfOverdueInstallments++; + totalOverdueAmount = totalOverdueAmount.plus(installment.getDepositAmountOutstanding(getCurrency())); + } + } + this.recurringDetail.updateOverdueDetails(noOfOverdueInstallments, totalOverdueAmount); + } + + @Override + public boolean allowWithdrawal() { + return this.recurringDetail.allowWithdrawal(); + } + + public boolean adjustAdvanceTowardsFuturePayments() { + return this.recurringDetail.adjustAdvanceTowardsFuturePayments(); + } + + @Override + public boolean isTransactionsAllowed() { + return isActive() || isAccountMatured(); + } + + public DepositAccountRecurringDetail getRecurringDetail() { + return this.recurringDetail; + } + + static class RecurringDepositScheduleInstallmentComparator implements Comparator { + + @Override + public int compare(final RecurringDepositScheduleInstallment o1, final RecurringDepositScheduleInstallment o2) { + return o1.installmentNumber().compareTo(o2.installmentNumber()); + } + } + + @Override + public void loadLazyCollections() { + this.depositScheduleInstallments.size(); + super.loadLazyCollections(); + this.chart.getId(); + } + + public BigDecimal getDepositAmount() { + return this.accountTermAndPreClosure.depositAmount(); + } + + @Override + public DepositAccountType depositAccountType() { + return DepositAccountType.fromInt(300); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ExtendTenureRecurringDepositAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ExtendTenureRecurringDepositAccountCommandHandler.java new file mode 100644 index 000000000..fa1ade4c4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/ExtendTenureRecurringDepositAccountCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/applyrdmissedinstallmentpenalty/ApplyRdMissedInstallmentPenaltyJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/applyrdmissedinstallmentpenalty/ApplyRdMissedInstallmentPenaltyJobConfig.java new file mode 100644 index 000000000..6a22d3fac --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/applyrdmissedinstallmentpenalty/ApplyRdMissedInstallmentPenaltyJobConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitJobConfig.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitJobConfig.java new file mode 100644 index 000000000..d4af8380d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitJobConfig.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitTasklet.java new file mode 100644 index 000000000..2926541db --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/executerdautodebit/ExecuteRdAutoDebitTasklet.java @@ -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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java index 5f956586a..cb9c978ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/jobs/updatedepositsaccountmaturitydetails/UpdateDepositsAccountMaturityDetailsTasklet.java @@ -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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java index b8df20388..87a0be708 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountPreMatureCalculationPlatformServiceImpl.java @@ -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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java index d6d943485..1b275f967 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformService.java @@ -61,6 +61,9 @@ public interface DepositAccountReadPlatformService { Collection retrieveForMaturityUpdate(); + /** Active recurring deposits that have not reached maturity yet - their posting periods are credited nightly. */ + Collection retrieveRunningRecurringDepositIds(); + SavingsAccountTransactionData retrieveRecurringAccountDepositTransactionTemplate(Long accountId); Collection retrieveDataForInterestTransfer(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java index 914fd86b1..6afb3a5b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountReadPlatformServiceImpl.java @@ -283,6 +283,18 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead return this.jdbcTemplate.query(sqlBuilder.toString(), DEPOSIT_ACCOUNT_LOOKUP_MAPPER, depositAccountType.getValue()); } + @Override + public Collection 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 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 42e258e8e..aa9afdb81 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -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 existingTransactionIds = new HashSet<>(); + final Set 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 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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 7b0fcd06d..2f1d19d5b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -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()) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index 5bb9f4f2c..e63446472 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java index 4a7709a25..ca8a01a19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAccountInterestCalculationServiceImpl.java @@ -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 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 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> 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 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> postings) { + final LocalDate termEnd = startDate.plusMonths(months).minusDays(1); + final MonetaryCurrency currency = terms.currency(); + + final List 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 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 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 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); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAutoDebitService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAutoDebitService.java new file mode 100644 index 000000000..5a55fac12 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositAutoDebitService.java @@ -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. + * + *
    + *
  • 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.
  • + *
  • 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.
  • + *
  • 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).
  • + *
+ */ +@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 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) {} +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositLienService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositLienService.java new file mode 100644 index 000000000..6b5b1f6e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositLienService.java @@ -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. + * + *

+ * 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: + *

    + *
  • only an RD whose product has Lien Marked 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;
  • + *
  • 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;
  • + *
  • which loans hold how much, so the RD screens can show it.
  • + *
+ */ +@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 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 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 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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositMissedInstallmentPenaltyService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositMissedInstallmentPenaltyService.java new file mode 100644 index 000000000..a6341b178 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositMissedInstallmentPenaltyService.java @@ -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"). + * + *

+ * 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. + * + *

    + *
  • 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.
  • + *
  • Paying an installment within the grace days costs nothing.
  • + *
  • 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.
  • + *
+ */ +@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 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 = 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductRules.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductRules.java new file mode 100644 index 000000000..1e57e7f2f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/RecurringDepositProductRules.java @@ -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. + * + *
    + *
  • Effective Date - the first day the product can be used. No account may be opened before it.
  • + *
  • 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.
  • + *
  • 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.
  • + *
  • Deposit Amount Min / Max - bounds on the installment the customer commits to each period.
  • + *
  • Nomination Facility - when on, at least one nominee is required; when off, nominees cannot be recorded.
  • + *
  • 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.
  • + *
  • Interest Calculation Type - copied onto the account as its simple/compounding flag (see + * {@link #isSimpleInterestOption}).
  • + *
+ */ +@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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index c936dba03..6e9342329 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index b6c0ce636..0b4d85c11 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -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)) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 976d2e6ce..8a49c6734 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 8aa8e172d..279de1120 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -167,4 +167,9 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_recurring_deposit_product_rules.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_recurring_deposit_product_rules.xml new file mode 100644 index 000000000..19b339999 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0594_recurring_deposit_product_rules.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + 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'); + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Execute RD Auto Debit' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_recurring_deposit_lien_percentage.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_recurring_deposit_lien_percentage.xml new file mode 100644 index 000000000..726edd301 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0595_recurring_deposit_lien_percentage.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0596_recurring_deposit_missed_installment_penalty.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0596_recurring_deposit_missed_installment_penalty.xml new file mode 100644 index 000000000..a99265448 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0596_recurring_deposit_missed_installment_penalty.xml @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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); + + + + + + SELECT COUNT(*) FROM job WHERE name = 'Apply RD Missed Installment Penalty' + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_enable_recurring_deposit_product_maker_checker.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_enable_recurring_deposit_product_maker_checker.xml new file mode 100644 index 000000000..3cb5d5e3e --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_enable_recurring_deposit_product_maker_checker.xml @@ -0,0 +1,88 @@ + + + + + + + + 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' + ); + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'CREATE_RECURRINGDEPOSITPRODUCT' + + + + UPDATE m_permission + SET can_maker_checker = TRUE + WHERE code = 'CREATE_RECURRINGDEPOSITPRODUCT'; + + + + + + 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 + ); + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0598_recurring_deposit_extend_tenure_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0598_recurring_deposit_extend_tenure_permission.xml new file mode 100644 index 000000000..4663ebe86 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0598_recurring_deposit_extend_tenure_permission.xml @@ -0,0 +1,51 @@ + + + + + + + + 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'); + + + + + + 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); + + + + diff --git a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java index c4db9dadf..5556626f2 100644 --- a/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java +++ b/fineract-provider/src/test/java/org/apache/fineract/commands/service/CommandSourceServiceTest.java @@ -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); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java index be62adada..e124319c9 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChart.java @@ -233,7 +233,11 @@ public class InterestRateChart extends AbstractPersistableCustom { 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); diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java index 50b891708..564cad140 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartSlabFields.java @@ -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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java index 78b18da87..596d30e93 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositAccountData.java @@ -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> 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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java index 3c6537c83..839b639ab 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/RecurringDepositProductData.java @@ -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 periodFrequencyTypeOptions = null; final Collection 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) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java index 136c60a45..d19b8b072 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountTermAndPreClosure.java @@ -250,6 +250,18 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom } 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 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 @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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java index b531290e5..2b5ff0bcb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsCoreEditFields.java @@ -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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index d47b85254..31b9e7a26 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -319,6 +319,13 @@ public class SavingsProduct extends AbstractPersistableCustom { @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 { @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 { 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 { 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 { 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 { 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; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 3c18db4df..afcf45061 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -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