From d2e13cc7b9363ba3c71c7846bd836ea3f1b468aa Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:55:25 +0530 Subject: [PATCH] feat(fd): dashboard-driven maturity renewal, plus fix stranded interest on Reinvest Principal Only A SINGLE/DOUBLE FD opened with an explicit maturity instruction still auto-processes on its own via the nightly job, exactly as before -- the grace period never delays that. An FD opened with NO instruction at all (the new default -- see the frontend's account-creation changes) is now never touched by the job; it just sits matured until staff renew it manually from the FD Dashboard, backdated to the original maturity date within the product's grace window, or from today with gap-period interest (at the linked account's rate) credited first once it's passed. New endpoints: - POST /fixeddepositaccounts/{id}/renew -- the Dashboard's own quick Reinvest choice (Principal+Interest / Principal Only), for the two-option case. - POST /fixeddepositaccounts/{id}/credit-gap-interest -- a pre-step the frontend's Payment dialog now calls before submitting the existing generic `payment` command, so the Dashboard's Renew action offers every closure type Payment does (Transfer to Savings, both Reinvest options, Partial Reinvest), not just two, while still crediting gap-period interest first. Also fixes a real, pre-existing bug found while building this: REINVEST_PRINCIPAL_ONLY never actually paid its leftover interest anywhere -- handleFDAccountMaturityClosure withdrew only the principal into the new deposit and never referenced its own toSavingsId parameter, silently stranding the interest on an account it also never closed. Now transfers the remainder to the target savings account and actually closes the old account. Co-Authored-By: Claude Sonnet 5 --- .../FixedDepositGapInterestApiResource.java | 66 +++++++ .../api/FixedDepositRenewalApiResource.java | 78 ++++++++ .../DepositAccountDomainServiceJpa.java | 28 +++ ...WritePlatformServiceJpaRepositoryImpl.java | 185 ++++++++++++++++++ .../savings/starter/SavingsConfiguration.java | 4 +- .../tenant/final-changelog-tenant.xml | 4 + ...l_grace_period_to_deposit_account_term.xml | 30 +++ .../DepositAccountWritePlatformService.java | 22 +++ 8 files changed, 415 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java new file mode 100644 index 000000000..dd5191542 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java @@ -0,0 +1,66 @@ +/** + * 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.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.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.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic + * `payment` command (the same one the account's own Account Actions menu already uses) -- so the + * Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options, + * Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the + * gap-period interest credited first when the product's renewal grace window has passed. A no-op + * (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all -- + * see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.") +@RequiredArgsConstructor +public class FixedDepositGapInterestApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String creditGapInterest(@PathParam("accountId") final Long accountId) { + // Posts a real interest transaction when past grace, so it needs write/update, not just read. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final Map result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java new file mode 100644 index 000000000..419e6247f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java @@ -0,0 +1,78 @@ +/** + * 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 com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.MediaType; +import java.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.DepositAccountWritePlatformService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +/** + * FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the + * original maturity date within the product's renewal grace window (0 days if none is configured), or + * from today with a gap-period interest credit (at the linked Savings account's rate) once the grace + * window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is + * made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored + * maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not + * consulted. All the actual renewal and gap-interest logic lives in + * DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same + * collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it. + */ +@Path("/v1/fixeddepositaccounts/{accountId}/renew") +@Component +@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.") +@RequiredArgsConstructor +public class FixedDepositRenewalApiResource { + + private final PlatformSecurityContext context; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final Gson gson = new Gson(); + + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String renew(@PathParam("accountId") final Long accountId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + // A renewal moves money (or credits gap-period interest), so it needs the write/update + // permission on this resource, not just read -- unlike the dashboard's own read-only endpoint. + this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject() + : JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject(); + final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull()) + ? body.get("reinvestType").getAsInt() + : null; + final Map result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType); + return this.gson.toJson(result); + } +} 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 62d049e95..a884d9f19 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 @@ -355,6 +355,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // an in-place renewal has nowhere to send. Left on the original flow rather than guessing. final BigDecimal maturedBalance = account.getAccountBalance(); final BigDecimal reInvestAmount = account.getDepositAmount(); + // Whatever is left after the principal moves into the new deposit is interest the customer + // earned and has to actually be paid out somewhere -- it must not just sit on this account, + // which is about to be closed. Validated before anything below runs, so a missing target + // fails cleanly with nothing done rather than leaving a half-completed reinvestment. + final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount); + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) { + throw new GeneralPlatformDomainRuleException( + "error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account", + "Reinvest Principal Only requires a Savings account to receive the leftover interest."); + } ExternalId externalId = this.externalIdFactory.create(); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); @@ -384,6 +394,24 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + // Pay out whatever didn't move into the new deposit, then actually close this account -- + // previously this branch left the leftover interest stranded on an account that was never + // closed, silently, because nothing past this point ever referenced toSavingsId at all. + if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) { + final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, + DepositAccountType.SAVINGS_DEPOSIT); + final boolean isExceptionForBalanceCheck = false; + final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest, + PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, + "Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null, + AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + account.updateClosedStatus(); + account.updateOnAccountClosureStatus(onClosureType); + } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); 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 788177150..4484134c7 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 @@ -28,6 +28,7 @@ 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.HashMap; import java.util.HashSet; @@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService; +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.SavingsAccountTransactionType; @@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository; import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail; import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccount; import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge; @@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; import org.apache.fineract.useradministration.domain.AppUser; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.annotation.Transactional; @Slf4j @@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo private final WorkingDaysRepositoryWrapper workingDaysRepository; private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository; private final DenominationBreakdownWriter denominationBreakdownWriter; + private final JdbcTemplate jdbcTemplate; @Transactional @Override @@ -1531,11 +1536,26 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); // handle maturity instructions + // An account opened WITH an explicit maturity instruction (Transfer to Savings or either + // Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the + // renewal grace period never delays this, no matter what the product has configured. Grace + // only matters for an account that was opened with NO instruction at all: that one is never + // touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a + // null closure type), so it just sits matured until a human renews it from the FD Dashboard + // -- see renewMaturedFixedDeposit(), which is where the grace window is actually read. if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) { DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); Map changes = new HashMap<>(); final AppUser user = context.authenticatedUser(); Long toSavingsId = fdAccount.getTransferToSavingsAccountId(); + // Reinvest Principal Only needs a target for the leftover interest (see + // DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening + // form only collects transferToSavingsId when Transfer to Savings itself was chosen, so + // an account opened straight into Reinvest Principal Only can reach here with none set. + // Falls back to the FD's own Deposit Account Link rather than failing the whole job run. + if (toSavingsId == null && fdAccount.isReinvestOnClosure()) { + toSavingsId = fdAccount.getLinkedAccountId(); + } this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(), fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes); @@ -1578,6 +1598,171 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds); } + @Transactional + @Override + public Map renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) { + final AppUser user = context.authenticatedUser(); + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet -- nothing to renew."); + } + // The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE + // FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because + // what happens at maturity is now decided here, by staff, at the moment of renewal. + final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType); + if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required", + "Choose Reinvest Principal + Interest or Reinvest Principal Only."); + } + // Only Principal pays the leftover interest out to the FD's own linked Savings account -- the + // account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this + // should always be present, but fail clearly rather than silently stranding the interest if not. + final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId(); + if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, " + + "so the leftover interest has somewhere to go."); + } + + // Renewal grace period only shrinks the backdate window below -- it is no longer a precondition + // for whether Renew is allowed at all. A product that never configured one behaves as a 0-day + // window: renewing on the maturity date itself backdates, any later renewal pays gap interest. + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays); + final boolean withinGrace = !today.isAfter(graceDeadline); + + final Map result = new HashMap<>(); + BigDecimal gapInterestAmount = null; + + if (!withinGrace) { + // Late: the backdated renewal is no longer offered. The gap between the original maturity + // date and today is compensated at the linked Savings account's own product rate (simple + // interest for that stretch) before the FD renews from today, so the new term's principal + // already includes it. + gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today); + } + + final LocalDate renewalDate = withinGrace ? maturityDate : today; + final Map changes = new HashMap<>(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType, + chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null, + withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate", + changes); + markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + // REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId); + // REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that + // actually carries the new term's maturity date/amount either way. + FixedDepositAccount renewedAccount = fdAccount; + if (changes.get("reinvestedDepositId") != null) { + renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"), + DepositAccountType.FIXED_DEPOSIT); + } + result.put("renewedAccountId", renewedAccount.getId()); + result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null); + result.put("maturityAmount", renewedAccount.maturityAmount()); + result.put("backdated", withinGrace); + if (gapInterestAmount != null) { + result.put("gapInterestAmount", gapInterestAmount); + } + return result; + } + + @Transactional + @Override + public Map creditGapInterestIfPastGrace(final Long fdAccountId) { + final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId, + DepositAccountType.FIXED_DEPOSIT); + if (!fdAccount.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured", + "This FD has not matured yet."); + } + + Integer renewalGracePeriodDays = null; + if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) { + renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays(); + } + if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) { + renewalGracePeriodDays = 0; + } + + final LocalDate maturityDate = fdAccount.maturityDate(); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays)); + + final Map result = new HashMap<>(); + result.put("withinGrace", withinGrace); + result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today)); + return result; + } + + /** + * Simple interest for the stretch between the FD's own maturity date and today, at the rate of its + * linked Savings/Current account's product -- credited onto the FD itself as a genuine + * INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the + * cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD + * Dashboard's generic Payment command) reads the account's balance. Shared by + * {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's + * Renew action now calls the latter as a pre-step before submitting the same generic `payment` + * command the account's own Account Actions menu already uses, so every closure type (Transfer to + * Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the + * two this method's sibling endpoint originally supported. + */ + private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) { + final Long linkedAccountId = fdAccount.getLinkedAccountId(); + if (linkedAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required", + "This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it " + + "before renewing late, so the gap-period interest has a rate to use."); + } + final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject(""" + SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa + JOIN m_savings_product sp ON sp.id = sa.product_id + WHERE sa.id = ? + """, BigDecimal.class, linkedAccountId); + + final long gapDays = ChronoUnit.DAYS.between(maturityDate, today); + final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365 + : fdAccount.getInterestCalculationDaysInYearType(); + final MathContext mc = MoneyHelper.getMathContext(); + final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc) + .multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc) + .setScale(2, java.math.RoundingMode.HALF_UP); + + if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) { + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds); + + final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(), + today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true); + fdAccount.addTransaction(gapInterestTxn); + fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions()); + this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount); + postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds); + } + return gapInterestAmount; + } + private void updateExistingTransactionsDetails(SavingsAccount account, Set existingTransactionIds, Set existingReversedTransactionIds) { existingTransactionIds.addAll(account.findExistingTransactionIds()); 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 1eef688a9..976d2e6ce 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 @@ -297,7 +297,7 @@ public class SavingsConfiguration { ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository, WorkingDaysRepositoryWrapper workingDaysRepository, DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository, - DenominationBreakdownWriter denominationBreakdownWriter + DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate ) { return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper, @@ -307,7 +307,7 @@ public class SavingsConfiguration { chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService, accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository, configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository, - denominationBreakdownWriter); + denominationBreakdownWriter, jdbcTemplate); } @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 545b6450b..9813f48f7 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 @@ -116,4 +116,8 @@ + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml new file mode 100644 index 000000000..ac4917332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + 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 979941229..11b836c37 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 @@ -92,5 +92,27 @@ public interface DepositAccountWritePlatformService { void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType); + /** + * 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 + * with a gap-period interest credit (at the linked Savings account's product rate) if not. + * {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and + * Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked + * Savings account); the account's own stored maturity instruction, if any, is not consulted. + * Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean), + * gapInterestAmount (BigDecimal, present only when backdated is false). + */ + Map renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType); + + /** + * Credits gap-period interest (simple interest, at the linked Savings/Current account's product + * rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning + * zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the + * generic `payment` command (the same one the account's own Account Actions menu uses), so every + * closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit} + * itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal). + */ + Map creditGapInterestIfPastGrace(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); }