From 46f5a1babd22f685284ff0bfb8cf6646fc273fe1 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 23 Aug 2026 16:44:11 +0530 Subject: [PATCH] feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action WHAT THIS CHANGES ================== Three related FD (Fixed Deposit) features, built incrementally in one session: 1. IN-PLACE RENEWAL -- when an FD matures with "Re-Invest Principal and Interest", the SAME m_savings_account row is now reused across every renewal (account number never changes) instead of closing the account and opening a new one. FixedDepositAccount.renewInPlace() does this by setting start_interest_calculation_date as a pivot: interest for terms before the pivot is never recalculated, only the new term's window is computed from there. A new one-to-many audit table m_fd_renewal_history records one row per term (renewal_no 0,1,2...) with that term's own principal, rate, dates, interest earned, maturity, and status. A companion table m_fd_renewal_chart_slab_history archives each closing term's full rate-by-tenure chart, captured just before renewInPlace overwrites it. 2. SENIOR-CITIZEN RATE -- the CBS Base Interest Rate master (m_cbs_base_interest_rate_slab_history) was the only place that ever stored a senior-citizen premium. It is now also persisted on m_savings_account_interest_rate_slab (new senior_citizen_rate column, nullable, added only to this account-level table -- NOT to the shared InterestRateChartSlabFields embeddable, so the product-level table m_interest_rate_slab is untouched). Applied both at account creation (DepositAccountAssembler now prices a new FD off the active CBS base version when one exists for that FD type, falling back to the product's own chart otherwise) and at renewal (renewInPlace refreshes the chart from the same base version). 3. PAYMENT ACTION -- a new manual command, POST /fixeddepositaccounts/{id}?command=payment, available whether the FD is ACTIVE or MATURED, unlike Close (Matured-only) and Premature Close (Active-only, no reinvest). Offers: Transfer to Savings, Re-Invest Principal and Interest (via renewInPlace), Re-Invest Principal Only (legacy close-and-reopen, now also transfers the leftover interest to a chosen savings account instead of stranding it), and a new closure type PARTIAL_REINVEST (500) -- part of the matured balance renews in place on the same account, the remainder transfers to a savings account picked at request time. Withdraw Deposit is deliberately excluded from this command. New permission PAYMENT_FIXEDDEPOSITACCOUNT plus its CHECKER twin were added in migration 0478. KEY FILES ========= - DepositAccountDomainServiceJpa.java: handleFDAccountPayment (new), renewInPlace call sites, recordInPlaceRenewal, senior-citizen rate matching with unit conversion across days/weeks and months/years families. - FixedDepositAccount.java: renewInPlace, validateRenewalDate, isLockedOnDate, fdAccountNumber/renewalCycleNo chain tracking. - SavingsAccount.java (SHARED with Savings and RD): added findLastTransactionBefore -- the interest-posting opening balance for a pivot-dated account now seeds from the last transaction strictly before the pivot, not the one on it. This fixes a bug where a renewed term could accrue zero interest, or double-subtract a same-day withdrawal, depending on transaction list order. Also widened calculateDateAccountIsLockedUntil to protected so renewInPlace can refresh lock-in on each renewed term. - DepositAccountTransactionDataValidator.java: new validatePayment. validateClosing now explicitly rejects PARTIAL_REINVEST (500) so it can never reach the Close or Premature-Close handlers, which have no branch for it and would otherwise pay the whole balance out in cash. This applies to both FD and RD, since they share validateClosing. - DepositAccountReadPlatformServiceImpl.java: retrieveOneWithClosureTemplate (Close and Premature Close, excludes PARTIAL_REINVEST) split from the new retrieveOneWithPaymentTemplate (Payment, includes it), sharing one private method with a flag. The renewal-history fallback query, for chains that predate m_fd_renewal_history, was rewritten to emit the exact same column shape as the primary query. - New: FixedDepositRenewalHistory plus its repository, FixedDepositRenewalChartSlabHistory plus its repository, PaymentFixedDepositAccountCommandHandler. - Migrations 0471 through 0478 under tenant/parts, each registered once in changelog-tenant.xml. RD AND SAVINGS IMPACT -- READ THIS IF SOMETHING BREAKS THERE ============================================================ RD and plain Savings share several of the touched files. Verified during this work: - handleRDAccountClosure and RecurringDepositAccount.java were not modified. - PARTIAL_REINVEST (500) is now rejected by validateClosing for both FD and RD close and prematureClose commands, and excluded from their template option lists. This was a real regression caught mid-session: the enum value briefly leaked into RD's Close dropdown as a working looking option that actually paid the whole RD balance out in cash. It was found and fixed before this commit. - The SavingsAccount.calculateInterestUsing opening-balance change only affects accounts where start_interest_calculation_date differs from activatedon_date. On the dev DB this was exactly two accounts, both FD test accounts created this session. Before deploying, run: SELECT deposit_type_enum, COUNT(*) FROM m_savings_account WHERE start_interest_calculation_date IS NOT NULL AND start_interest_calculation_date <> activatedon_date GROUP BY deposit_type_enum; Any non-FD rows (100 or 300) there mean a Savings or RD account will get a different, corrected interest amount than before. This is expected to be rare or zero, but confirm before deploy. - DepositAccountAssembler's creation-time base-rate pricing is guarded by depositAccountType.isFixedDeposit, so RD account creation is unaffected. - is_active on InterestRateChart (migration 0474) is read only by FixedDepositProduct.activeChart, which only FD's renewInPlace and reInvest call. RD's applicableChart path is untouched. Migration 0474 also retires (is_active = false) any chart whose end_date is already past. On the dev DB this retired zero rows; check on the target DB before deploy, since there is currently no API to reactivate a retired chart. KNOWN CAVEATS -- NOT YET DONE, FLAGGING RATHER THAN HIDING ============================================================ - Migrations 0471 through 0477, the DDL ones, have never actually executed via Liquibase. They were mirrored by hand directly into the dev DB (ADD COLUMN IF NOT EXISTS, CREATE TABLE IF NOT EXISTS) so testing could proceed without a full rebuild each time. Liquibase will mark them MARK_RAN via preConditions on the dev DB's next real run, but their first genuine execution will be on whatever environment does not already have these columns and tables, such as a fresh QA or production database. Test a migration-from-scratch run before relying on this in production. - This code has not been compiled. Every new or changed method signature, import, and call site was manually cross-checked with grep against real declarations, but that is not a substitute for gradle actually compiling it. Run a full build before deploying and treat any compiler error as expected until confirmed clean. - DepositAccountAssembler pricing every new FD off the active CBS base rate master, instead of the product's own chart, whenever one exists is a real behaviour change affecting all new FD accounts of a type that has a base-rate version, not just senior-citizen ones. Flagging this for explicit product sign-off, not assuming it in this commit. Co-Authored-By: Claude Sonnet 5 --- .../service/CommandWrapperBuilder.java | 9 + .../data/SavingsAccountSummaryData.java | 17 +- .../savings/DepositAccountOnClosureType.java | 8 +- .../savings/DepositsApiConstants.java | 1 + .../savings/service/SavingsEnumerations.java | 4 + ...sReadPlatformServiceJpaRepositoryImpl.java | 19 +- .../api/FixedDepositAccountsApiResource.java | 7 +- .../data/DepositAccountDataValidator.java | 18 + ...epositAccountTransactionDataValidator.java | 84 ++- .../domain/DepositAccountAssembler.java | 30 +- .../domain/DepositAccountDomainService.java | 3 + .../DepositAccountDomainServiceJpa.java | 593 +++++++++++++++++- .../savings/domain/FixedDepositAccount.java | 284 ++++++++- .../FixedDepositRenewalChartSlabHistory.java | 99 +++ ...ositRenewalChartSlabHistoryRepository.java | 21 + .../domain/FixedDepositRenewalHistory.java | 158 +++++ .../FixedDepositRenewalHistoryRepository.java | 30 + ...mentFixedDepositAccountCommandHandler.java | 46 ++ ...erestRateChartReadPlatformServiceImpl.java | 4 +- .../DepositAccountReadPlatformService.java | 6 + ...DepositAccountReadPlatformServiceImpl.java | 141 ++++- ...WritePlatformServiceJpaRepositoryImpl.java | 62 ++ .../db/changelog/tenant/changelog-tenant.xml | 8 + ..._add_fd_account_number_to_deposit_term.xml | 24 + ...2_add_renewal_cycle_no_to_deposit_term.xml | 23 + .../parts/0473_create_fd_renewal_history.xml | 81 +++ ...4_add_is_active_to_interest_rate_chart.xml | 40 ++ ...5_create_fd_renewal_chart_slab_history.xml | 50 ++ ...ior_citizen_rate_to_account_chart_slab.xml | 27 + ..._rate_to_fd_renewal_chart_slab_history.xml | 21 + .../parts/0478_add_fd_payment_permission.xml | 29 + .../domain/InterestRateChart.java | 23 + .../domain/InterestRateChartFields.java | 4 + .../domain/InterestRateChartSlabFields.java | 4 + ...positAccountInterestRateChartSlabData.java | 22 +- .../savings/data/FixedDepositAccountData.java | 30 + .../DepositAccountInterestRateChart.java | 86 +++ .../DepositAccountInterestRateChartSlabs.java | 29 + .../DepositAccountTermAndPreClosure.java | 44 ++ .../savings/domain/FixedDepositProduct.java | 36 ++ .../savings/domain/SavingsAccount.java | 38 +- .../DepositAccountWritePlatformService.java | 2 + 42 files changed, 2215 insertions(+), 50 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml 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 5e2c31402..ae8f7d0b9 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 @@ -2897,6 +2897,15 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder paymentFixedDepositAccount(final Long accountId) { + this.actionName = "PAYMENT"; + this.entityName = "FIXEDDEPOSITACCOUNT"; + this.entityId = accountId; + this.savingsId = accountId; + this.href = "/fixeddepositaccounts/" + accountId + "?command=payment"; + return this; + } + public CommandWrapperBuilder fixedDepositAccountInterestCalculation(final Long accountId) { this.actionName = "CALCULATEINTEREST"; this.entityName = "FIXEDDEPOSITACCOUNT"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java index 009e634eb..191a8d7af 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/accountdetails/data/SavingsAccountSummaryData.java @@ -72,11 +72,26 @@ public class SavingsAccountSummaryData { // differentiate deposit accounts Savings, FD and RD accounts private final EnumOptionData depositType; + /** + * For a Fixed Deposit, the stable customer-facing FD number: the account number of the first account in the renewal + * chain, which stays the same across every renewal even though each term is its own account row. Equals accountNo + * for an FD that has never been renewed, and is null for non-deposit accounts. + */ + private final String fdAccountNumber; + /** + * Which renewal cycle of that FD this row is: 0 for the originally opened account, 1 for the first renewal, and so + * on. Lets a client's account list collapse a renewal chain into one FD instead of listing each term separately. + */ + private final Integer renewalCycleNo; + public SavingsAccountSummaryData(final Long id, final String accountNo, final String externalId, final Long productId, final String productName, final String shortProductName, final SavingsAccountStatusEnumData status, final CurrencyData currency, final BigDecimal accountBalance, final BigDecimal onHoldFunds, final BigDecimal savingsAmountOnHold, final BigDecimal availableBalance, final EnumOptionData accountType, final SavingsAccountApplicationTimelineData timeline, - final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate) { + final EnumOptionData depositType, final SavingsAccountSubStatusEnumData subStatus, final LocalDate lastActiveTransactionDate, + final String fdAccountNumber, final Integer renewalCycleNo) { + this.fdAccountNumber = fdAccountNumber; + this.renewalCycleNo = renewalCycleNo; this.id = id; this.accountNo = accountNo; this.externalId = externalId; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java index f5067ae6c..c4939917b 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositAccountOnClosureType.java @@ -31,7 +31,8 @@ public enum DepositAccountOnClosureType { WITHDRAW_DEPOSIT(100, "depositAccountClosureType.withdrawDeposit"), // TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), // REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), // - REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"); // + REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), // + PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); // private final Integer value; private final String code; @@ -51,6 +52,7 @@ public enum DepositAccountOnClosureType { case 200 -> TRANSFER_TO_SAVINGS; case 300 -> REINVEST_PRINCIPAL_AND_INTEREST; case 400 -> REINVEST_PRINCIPAL_ONLY; + case 500 -> PARTIAL_REINVEST; default -> INVALID; }; } @@ -85,6 +87,10 @@ public enum DepositAccountOnClosureType { return this.equals(INVALID); } + public boolean isPartialReinvest() { + return this.equals(PARTIAL_REINVEST); + } + // TODO: do we really need this?!? public static Object[] integerValues() { return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray(); 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 078d9aaf4..f3e7eba4f 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 @@ -208,6 +208,7 @@ public final class DepositsApiConstants { public static final String onAccountClosureIdParamName = "onAccountClosureId"; public static final String transferDescriptionParamName = "transferDescription"; public static final String toSavingsAccountIdParamName = "toSavingsAccountId"; + public static final String reinvestAmountParamName = "reinvestAmount"; public static final String savingsAccounts = "savingsAccounts"; public static final String maturityInstructionIdParamName = "maturityInstructionId"; public static final String transferToSavingsIdParamName = "transferToSavingsId"; diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java index 57e025a06..3ed32ff1c 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsEnumerations.java @@ -797,6 +797,10 @@ public final class SavingsEnumerations { optionData = new EnumOptionData(DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getValue().longValue(), DepositAccountOnClosureType.REINVEST_PRINCIPAL_ONLY.getCode(), "Re-Invest Principal Only"); break; + case PARTIAL_REINVEST: + optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(), + DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest"); + break; } return optionData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java index 5285eb94e..32de4e05b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/accountdetails/service/AccountDetailsReadPlatformServiceJpaRepositoryImpl.java @@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, "); accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, "); accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, "); - accountsSummary.append("sa.deposit_type_enum as depositType "); + accountsSummary.append("sa.deposit_type_enum as depositType, "); + // Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and + // this row's cycle within the chain, so a client's account list can collapse a renewed + // FD into one entry rather than listing every term as a separate account. + accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo "); accountsSummary.append("from m_savings_account sa "); accountsSummary.append("join m_savings_product as p on p.id = sa.product_id "); + accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id "); accountsSummary.append("join m_currency curr on curr.code = sa.currency_code "); accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid "); accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid "); @@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname, activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname); + // Null for a savings/current account and for an FD opened before this column existed; + // callers fall back to accountNo, so an FD that has never been renewed still reads as + // its own FD number. + final String fdAccountNumber = rs.getString("fdAccountNumber"); + final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo"); + return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus, - lastActiveTransactionDate); + lastActiveTransactionDate, fdAccountNumber, renewalCycleNo); } } @@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400); + // Current accounts have no deposit term, so no FD renewal chain to report. return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency, - accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate); + accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate, + null, null); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index f3775ff8a..8d6e0c241 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -440,6 +440,9 @@ public class FixedDepositAccountsApiResource { } else if (is(commandParam, "prematureClose")) { final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build(); result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); + } else if (is(commandParam, "payment")) { + final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build(); + result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest); } else if (is(commandParam, "calculatePrematureAmount")) { final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson); final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper); @@ -453,7 +456,7 @@ public class FixedDepositAccountsApiResource { if (result == null) { throw new UnrecognizedQueryParamException("command", commandParam, new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest", - "postInterest", "close", "prematureClose", "calculatePrematureAmount" }); + "postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" }); } return this.toApiJsonSerializer.serialize(result); @@ -489,6 +492,8 @@ public class FixedDepositAccountsApiResource { DepositAccountData account = null; if (is(commandParam, "close")) { account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId); + } else if (is(commandParam, "payment")) { + account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId); } final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java index 995811ca1..3aa401060 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java @@ -366,6 +366,15 @@ public class DepositAccountDataValidator { "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); } + // Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is + // nothing sensible to pre-commit to years in advance as a standing instruction, so it is only + // ever chosen manually via the Payment action, never set here. + if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.partial.reinvest.not.allowed", + "Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() @@ -529,6 +538,15 @@ public class DepositAccountDataValidator { "Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option."); } + // Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is + // nothing sensible to pre-commit to years in advance as a standing instruction, so it is only + // ever chosen manually via the Payment action, never set here. + if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) { + baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage( + "maturity.instruction.partial.reinvest.not.allowed", + "Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time."); + } + if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) { final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element); baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java index 03b69a1da..841776e50 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountTransactionDataValidator.java @@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator { private static final Set DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName)); + private static final Set DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>( + Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName, + DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName, + transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName, + bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName, + DepositsApiConstants.reinvestAmountParamName)); + @Autowired public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) { this.fromApiJsonHelper = fromApiJsonHelper; @@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator { if (onAccountClosureId != null) { final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (accountOnClosureType.isTransferToSavings()) { + if (accountOnClosureType.isPartialReinvest()) { + // Partial Re-Invest needs a kept-amount and a destination account that only the Payment + // command collects, and only its handler implements. Neither the FD nor the RD closure + // handler has a branch for it, so without this it would fall through to their terminal + // "withdraw the whole balance" case and silently pay the deposit out in cash. + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode( + "partial.reinvest.not.allowed", + "Partial Re-Invest is only supported through the Payment action, not account closure"); + } else if (accountOnClosureType.isTransferToSavings()) { final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element); baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId) .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, @@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator { throwExceptionIfValidationWarningsExist(dataValidationErrors); } + /** + * Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is + * for Close/Premature Close, so this never changes either of their existing behavior. Unlike + * {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction -- + * Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own + * amount and destination-account rules. + */ + public void validatePayment(final JsonCommand command) { + final String json = command.json(); + + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS); + + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(DepositAccountType.FIXED_DEPOSIT.resourceName()); + + final JsonElement element = command.parsedJson(); + + final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element); + baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull(); + + final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element); + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank() + .isOneOfTheseValues(DepositAccountOnClosureType.integerValues()); + + if (onAccountClosureId != null) { + final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + if (accountOnClosureType.isWithdarwDeposit()) { + baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage( + "withdraw.deposit.not.allowed.for.payment", + "Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option."); + } + // Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same + // destination-account rule Transfer to Savings and Partial Reinvest already need. + if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest() + || accountOnClosureType.isReinvestPrincipal()) { + final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element); + baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId) + .cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode()); + } + if (accountOnClosureType.isPartialReinvest()) { + final BigDecimal reinvestAmount = this.fromApiJsonHelper + .extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element); + baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull() + .positiveAmount(); + } + } + + // Validate all string payment detail fields for max length + final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element); + baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero(); + final Set paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName, + routingCodeParamName, receiptNumberParamName, bankNumberParamName)); + for (final String paymentDetailParameterName : paymentDetailParameters) { + final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element); + baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull() + .notExceedingLengthOf(50); + } + + throwExceptionIfValidationWarningsExist(dataValidationErrors); + } + private void throwExceptionIfValidationWarningsExist(final List dataValidationErrors) { if (!dataValidationErrors.isEmpty()) { // diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java index 6fde2049b..7fa657aa5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountAssembler.java @@ -87,6 +87,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException; import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException; import org.apache.fineract.portfolio.group.exception.GroupNotActiveException; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; @@ -97,9 +98,11 @@ 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.data.FdInterestRateEffectiveData; import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException; import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException; import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -123,6 +126,7 @@ public class DepositAccountAssembler { private final ExternalIdFactory externalIdFactory; private final SavingsAccountAssembler savingsAccountAssembler; + private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; @Autowired public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper, @@ -134,7 +138,8 @@ public class DepositAccountAssembler { final RecurringDepositProductRepository recurringDepositProductRepository, final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context, final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory, - final SavingsAccountAssembler savingsAccountAssembler) { + final SavingsAccountAssembler savingsAccountAssembler, + final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) { this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper; this.clientRepository = clientRepository; @@ -151,6 +156,7 @@ public class DepositAccountAssembler { this.paymentDetailAssembler = paymentDetailAssembler; this.externalIdFactory = externalIdFactory; this.savingsAccountAssembler = savingsAccountAssembler; + this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService; } /** @@ -322,6 +328,28 @@ public class DepositAccountAssembler { accountChart = DepositAccountInterestRateChart.from(productChart); } + // A new FD priced from its product's own chart never carries a senior-citizen premium -- that + // concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a + // specific chart, price the account from that master's currently active version instead (the same + // one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows + // its premium from day one rather than only after its first renewal. RD is unaffected: this only + // runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate + // version exists for the product's FD type, in which case the product's own chart above stands. + if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName) + && product instanceof FixedDepositProduct) { + final String fdType = ((FixedDepositProduct) product).getFdType(); + if (fdType != null) { + final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService + .retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null); + if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) { + final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates); + final Collection baseChartSlabs = DepositAccountDomainServiceJpa + .baseChartSlabsOf(activeBaseRates, product.currency().getCode()); + accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs); + } + } + } + boolean withHoldTax = product.withHoldTax(); if (command.parameterExists(withHoldTaxParamName)) { withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName); 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 aff6abadd..82e640fb2 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 @@ -64,4 +64,7 @@ public interface DepositAccountDomainService { Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command, Map changes); + + Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command, + Map changes); } 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 05f48a177..62d049e95 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 @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.savings.domain; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName; @@ -26,6 +27,9 @@ 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.HashSet; import java.util.List; import java.util.Locale; @@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository; import org.apache.fineract.portfolio.calendar.domain.CalendarType; import org.apache.fineract.portfolio.calendar.service.CalendarUtils; import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields; import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail; 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.SavingsApiConstants; +import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType; import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues; +import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData; +import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData; +import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService; import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService; import org.apache.fineract.useradministration.domain.AppUser; import org.springframework.stereotype.Service; @@ -80,6 +90,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository; private final CalendarInstanceRepository calendarInstanceRepository; private final ExternalIdFactory externalIdFactory; + private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository; + private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository; + private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService; @Transactional @Override @@ -220,8 +233,11 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); if (onClosureType.isReinvest()) { + // Captured before the withdrawal below empties the account, so the renewal history can + // record what this term actually matured at. + final BigDecimal maturedBalance = account.getAccountBalance(); ExternalId externalId = this.externalIdFactory.create(); - FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId); + FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId); this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); @@ -230,9 +246,13 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // Renewal is an internal movement into the new deposit, not cash paid out -- post it // against the transfer-suspense GL rather than crediting cash/vault. final boolean isInternalTransferLeg = true; - final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), - paymentDetail, false, isRegularTransaction, isInternalTransferLeg); + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail, + false, isRegularTransaction, isInternalTransferLeg); savingsTransactionId = withdrawal.getId(); + // Reached when a user closes the account with a re-invest instruction, rather than the + // maturity job acting on a standing instruction. + recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); } else if (onClosureType.isTransferToSavings()) { final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName); final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName); @@ -283,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); account.setClosedOnDate(closedDate); final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); - if (onClosureType.isReinvest()) { - BigDecimal reInvestAmount; - if (onClosureType.isReinvestPrincipal()) { - reInvestAmount = account.getDepositAmount(); - } else { - reInvestAmount = account.getAccountBalance(); - } + if (onClosureType.isReinvestPrincipalAndInterest()) { + // Renews on the SAME account, so the customer keeps one account number however many times + // the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new + // term's principal, so there is no withdrawal, no deposit and no journal entry for the + // rollover itself -- which also removes the transfer-suspense round trip the old + // close-and-reopen flow needed. + final BigDecimal closingPrincipal = account.getDepositAmount(); + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null + : maturedBalance.subtract(closingPrincipal); + + // The active CBS base interest-rate version for this FD type: the same rate card the Base + // Interest Rates screen shows and the account-opening wizard prices from. Used for both the + // new term's slabs and its senior-citizen premium, so screen and account agree. + final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null + : this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst() + .orElse(null); + + final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen() + ? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency()) + : null; + + // Captured before renewInPlace overwrites the account's chart, so the closing term's full + // rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost + // -- renewInPlace mutates the same chart row every time, it does not create a new one. + final String closingChartName = account.currentChartName(); + final List closingChartSlabs = account.currentChartSlabFields(); + // Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new + // term's -- needed only if this is the closing term's first-ever history row (see + // recordInPlaceRenewal), so that row records what this term actually ran at instead of blank. + final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate(); + final Integer closingTenure = account.depositPeriod(); + final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); + + account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingsAccountRepository.save(account); + + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate, + closingTenure, closingTenureFrequencyEnum); + changes.put("renewedInPlace", Boolean.TRUE); + changes.put("renewalCycleNo", account.renewalCycleNo()); + + } else if (onClosureType.isReinvest()) { + // "Re-invest principal only" still closes and reopens, because only the principal carries + // forward and the interest has to leave the deposit -- which needs a payout destination that + // 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(); ExternalId externalId = this.externalIdFactory.create(); FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); @@ -311,14 +376,14 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi // postJournalEntries() call at the end of this method posts it a second time. updateAlreadyPostedTransactions(existingTransactionIds, account); - if (onClosureType.isReinvestPrincipalAndInterest()) { - account.updateClosedStatus(); - account.updateOnAccountClosureStatus(onClosureType); - } changes.put("reinvestedDepositId", reinvestedDeposit.getId()); reinvestedDeposit.approveAndActivateApplication(closedDate, user); this.savingsAccountRepository.save(reinvestedDeposit); + // Reached from the maturity job processing the account's standing maturity instruction. + recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user); + } else if (onClosureType.isTransferToSavings()) { final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId, DepositAccountType.SAVINGS_DEPOSIT); @@ -461,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi } } + /** + * Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium -- + * against its {@link FixedDepositRenewalHistory} row. + *

+ * Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the + * closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive. + */ + private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName, + final List snapshots, final String currencyCode) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) { + final InterestRateChartSlabFields fields = snapshot.getFields(); + if (fields.annualInterestRate() == null || fields.fromPeriod() == null) { + continue; + } + this.fixedDepositRenewalChartSlabHistoryRepository + .save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(), + fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(), + fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate())); + } + } + + /** + * Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private + * and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a + * new FD account is first priced from this master, instead of duplicating the logic. + */ + static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) { + // name and from_date are NOT NULL on the embeddable. A base version missing either would abort the + // whole renewal (or account creation) with a constraint violation, so treat it as "no identity to + // apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and + // the slabs -- the part that actually prices the deposit -- still get applied. + if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) { + return null; + } + final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount + return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(), + baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount); + } + + /** + * Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen + * premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live. + *

+ * The base master bands purely by tenure, so amount ranges are left unset -- which + * {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no + * from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure. + */ + static Collection baseChartSlabsOf(final FdInterestRateEffectiveData baseRates, + final String currencyCode) { + if (baseRates == null || baseRates.getSlabs() == null) { + return Collections.emptyList(); + } + final List snapshots = new ArrayList<>(); + for (final FdInterestRateSlabData slab : baseRates.getSlabs()) { + if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) { + continue; + } + final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(), + SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null, + slab.getAnnualInterestRate(), currencyCode); + snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate())); + } + return snapshots; + } + + /** + * The senior-citizen premium from the active base rate card for a given tenure. + *

+ * The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years + * still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or + * months/years -- because only those are exact; a days-based card is therefore still never matched against a + * months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the + * wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the + * account's existing rate untouched rather than silently dropping the depositor's premium. + */ + private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure, + final Integer tenurePeriodTypeEnum) { + if (baseRates == null || baseRates.getSlabs() == null || tenure == null) { + return null; + } + final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum); + if (tenureInBaseUnit == null) { + return null; + } + for (final FdInterestRateSlabData slab : baseRates.getSlabs()) { + if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) { + continue; + } + final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum()); + if (slabFrom == null || tenureInBaseUnit < slabFrom) { + continue; + } + if (slab.getToPeriod() != null) { + final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum()); + if (slabTo == null || tenureInBaseUnit > slabTo) { + continue; + } + } + final BigDecimal rate = slab.getSeniorCitizenRate(); + if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) { + return rate; + } + } + return null; + } + + /** + * {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so + * two periods written in different but related units can be compared without losing precision. Null for an unknown + * unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3). + */ + private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) { + if (value == null || periodTypeEnum == null) { + return null; + } + return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) { + case DAYS, MONTHS -> value; + case WEEKS -> value * 7; + case YEARS -> value * 12; + default -> null; + }; + } + + /** + * Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those + * two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never + * matched against a months-based tenure rather than guessing and landing the deposit in the wrong band. + */ + private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) { + if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) { + return false; + } + return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum); + } + + private boolean isDayFamily(final Integer periodTypeEnum) { + final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum); + return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS; + } + + /** + * Records a renewal that happened on the SAME account, where there is no second account row to describe the new term. + *

+ * Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number, + * principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the + * account, where they no longer exist. Every row for one FD shares that FD's single account id and account number; + * they are told apart by renewal_no, which is what makes this a one-to-many history of one account. + * + * @param closingChartName + * the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if + * unavailable + * @param closingChartSlabs + * the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived + * against the closing term's row so its whole rate card remains queryable, not only the single effective + * rate {@link FixedDepositRenewalHistory} records. + * @param closingInterestRate + * the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it -- + * needed only when this term has no existing row yet (see below), since the account itself no longer + * carries it once renewInPlace has run. + * @param closingTenure + * the closing term's own deposit period, captured the same way and needed for the same reason. + * @param closingTenureFrequencyEnum + * the closing term's own deposit period frequency, captured the same way and needed for the same reason. + */ + private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal, + final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount, + final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName, + final List closingChartSlabs, final BigDecimal closingInterestRate, + final Integer closingTenure, final Integer closingTenureFrequencyEnum) { + + // The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old + // close-and-reopen model before renewing in place: its rows are keyed on the original account's + // number, so keying new rows on this account's number would split the history in two and re-create + // a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's + // own number when there is no chain, which is what the read side resolves to as well. + final String fdAccountNumber = account.fdAccountNumber(); + final int newCycleNo = account.renewalCycleNo(); + final Long createdById = user == null ? null : user.getId(); + + final List existingRows = this.fixedDepositRenewalHistoryRepository + .findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber); + FixedDepositRenewalHistory closingRow = existingRows.stream() + .filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null); + + // The term that just closed has no row when this FD renews for the first time since the history + // table existed. Written now, from the figures captured before the rollover, so the history + // always begins with the term the customer originally opened -- rate and tenure included, rather + // than left blank, since the caller captured them before renewInPlace overwrote the account with + // the new term's own values. + if (closingRow == null) { + closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber, + account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance, + closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure, + closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById)); + } + closingRow.recordOutcome(closingInterestEarned, maturedBalance); + closingRow.markMatured(); + this.fixedDepositRenewalHistoryRepository.save(closingRow); + + archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode()); + + // The new term's own interest/maturity stay unset until it closes in turn. + this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(), + account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount, + account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(), + account.depositPeriodFrequency(), renewalType, renewedOn, createdById)); + } + + /** + * Records the renewal that started {@code newTerm}, and closes off the previous term's history row. + *

+ * Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event + * itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not + * recoverable from the account rows alone. This writes it down explicitly. + *

+ * The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only + * gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no + * original. + */ + private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm, + final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType, + final AppUser user) { + + final String fdAccountNumber = previousTerm.fdAccountNumber(); + final Long originalAccountId = this.fixedDepositRenewalHistoryRepository + .findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst() + .map(FixedDepositRenewalHistory::getOriginalSavingsAccountId) + .orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null); + + FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository + .findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null); + + // Backfill the opening term when the chain started before this table existed, so the history + // always begins with the account the customer actually opened. + if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) { + previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber, + originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(), + previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(), + previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(), + previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(), + user == null ? null : user.getId())); + } + + // What the closing term actually earned. maturityAmount is passed in rather than read off the + // account because the caller has already withdrawn the balance out of it by this point, which + // would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own + // term's outcome, so a reader never has to look at the next row to find out how one term did. + final BigDecimal previousPrincipal = previousTerm.getDepositAmount(); + final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null + : maturityAmount.subtract(previousPrincipal); + + if (previousRow != null) { + previousRow.recordOutcome(interestEarned, maturityAmount); + previousRow.markMatured(); + this.fixedDepositRenewalHistoryRepository.save(previousRow); + } + + // The new term's own interest/maturity stay unset until it closes in turn. + this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, + originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(), + previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount, + newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(), + newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId())); + } + @Transactional @Override public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user, @@ -519,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi return savingsTransactionId; } + /** + * A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close + * (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support + * -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest + * transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through + * {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what + * the existing manual Close command still uses today. + *

+ * Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} -- + * neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as + * before. + */ + @Transactional + @Override + public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user, + final JsonCommand command, final Map changes) { + + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + + boolean isRegularTransaction = false; + boolean isAccountTransfer = false; + final boolean isPreMatureClosure = false; + final Set existingTransactionIds = new HashSet<>(); + final Set existingReversedTransactionIds = new HashSet<>(); + updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds); + + final MathContext mc = MathContext.DECIMAL64; + final Locale locale = command.extractLocale(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale); + final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + Long savingsTransactionId = null; + + // account.isMatured() only reflects whether the overnight scheduler has already flipped this + // account's status -- not whether the customer actually showed up before or after the maturity + // date. A customer walking in ON the maturity date (or any day after) is not premature just + // because that job hasn't run yet today, so the real test is the date, not the status flag. + if (!account.isMatured()) { + if (!DateUtils.isBefore(closedDate, account.maturityDate())) { + // Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would: + // flip to Matured and post final interest at the full contracted rate, no penalty. + account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } else { + // The three closing closure types re-check this themselves inside prematureClosure() + // below, but the two reinvest branches never call it (they keep the account open on the + // same term), so it is enforced once, up front, for every genuinely-premature Payment -- + // a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via + // this action either. + if (account.isLockedOnDate(closedDate)) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed", + "This account is still within its lock-in period and cannot be processed before it elapses.", closedDate); + } + // Same penalty-aware interest calculation prematureClosure() already relies on, so a + // genuinely early Payment settles interest exactly as a Premature Close would. + account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } + } else { + account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + } + + // Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads + // getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set + // before branching. renewInPlace() clears it again for the branches that keep the account open. + account.setClosedOnDate(closedDate); + + // Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now + // correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way. + final boolean alreadyMatured = account.isMatured(); + + final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName); + final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId); + + if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) { + // These two branches keep the account open, so they never reach close()/prematureClosure() and + // would otherwise accept any date at all -- including one behind already-posted interest, which + // the renewal would then turn into a pivot that reverses and re-posts it. + account.validateRenewalDate(closedDate); + + final BigDecimal closingPrincipal = account.getDepositAmount(); + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null + : maturedBalance.subtract(closingPrincipal); + + final BigDecimal renewedAmount; + if (onClosureType.isPartialReinvest()) { + renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName); + if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0 + || renewedAmount.compareTo(maturedBalance) >= 0) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid", + "The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount); + } + } else { + renewedAmount = maturedBalance; + } + + final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null + : this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst() + .orElse(null); + final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen() + ? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency()) + : null; + + final String closingChartName = account.currentChartName(); + final List closingChartSlabs = account.currentChartSlabFields(); + // Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new + // term's -- needed only if this is the closing term's first-ever history row (see + // recordInPlaceRenewal), so that row records what this term actually ran at instead of blank. + final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate(); + final Integer closingTenure = account.depositPeriod(); + final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency(); + + account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates), + baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode())); + account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + this.savingsAccountRepository.save(account); + + recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate, + closingTenure, closingTenureFrequencyEnum); + changes.put("renewedInPlace", Boolean.TRUE); + changes.put("renewalCycleNo", account.renewalCycleNo()); + + if (onClosureType.isPartialReinvest()) { + // The kept portion never left the account -- renewInPlace() just relabels it as the new + // term's principal -- so only the remainder is an actual money movement, posted the same + // way the Transfer to Savings branch below posts its full-balance transfer. + final BigDecimal remainder = maturedBalance.subtract(renewedAmount); + 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, remainder, 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, + isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + + } else if (onClosureType.isReinvestPrincipal()) { + // Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch -- + // the interest has to leave the deposit, which needs a payout destination an in-place renewal + // has nowhere to send. + final BigDecimal maturedBalance = account.getAccountBalance(); + final BigDecimal reInvestAmount = account.getDepositAmount(); + final ExternalId externalId = this.externalIdFactory.create(); + + final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId); + this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit); + reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + + this.savingsAccountRepository.saveAndFlush(reinvestedDeposit); + autoGenerateAccountNumber(reinvestedDeposit); + final boolean isInternalTransferLeg = true; + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail, + false, isRegularTransaction, isInternalTransferLeg); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + changes.put("reinvestedDepositId", reinvestedDeposit.getId()); + reinvestedDeposit.approveAndActivateApplication(closedDate, user); + this.savingsAccountRepository.save(reinvestedDeposit); + + recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate, + FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user); + + // Only the principal moved into the new deposit -- the interest earned on this term is still + // sitting in this account's balance. Pay it out to the chosen savings account now, before + // closing, rather than stranding it here with nowhere left to go. + final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount); + if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) { + 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, interestRemainder, + 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, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + } + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + + } 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, 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, isAccountTransfer, isExceptionForBalanceCheck); + this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + + } else { + final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(), + paymentDetail, false, isRegularTransaction); + savingsTransactionId = withdrawal.getId(); + updateAlreadyPostedTransactions(existingTransactionIds, account); + + if (alreadyMatured) { + account.close(user, command, changes); + } else { + account.prematureClosure(user, command, changes); + } + } + + this.savingsAccountRepository.save(account); + postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer); + + return savingsTransactionId; + } + @Transactional @Override public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java index 95cff6e3f..47b02dcf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositAccount.java @@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.group.domain.Group; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler; import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositAccountType; @@ -276,7 +277,9 @@ public class FixedDepositAccount extends SavingsAccount { public LocalDate calculateMaturityDate() { - final LocalDate startDate = accountSubmittedOrActivationDate(); + // The CURRENT term's start, not the account's activation date -- on a renewed FD these differ, + // and measuring the term from activation would put maturity in the past. + final LocalDate startDate = depositStartDate(); LocalDate maturityDate = null; final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod(); switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) { @@ -312,13 +315,19 @@ public class FixedDepositAccount extends SavingsAccount { .fromInt(this.interestCalculationDaysInYearType); List postedAsOnTransactionDates = getManualPostingDates(); + // Scoped to the current term, so a renewed FD's projection covers only the term now running -- + // matching what postInterest() will actually post -- rather than replaying the whole account. final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( - accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, - postedAsOnTransactionDates); + depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates); final List allPostingPeriods = new ArrayList<>(); - Money periodStartingBalance = Money.zero(currency); + // On a first term the opening deposit falls inside the first posting period, so the period can + // start from zero and pick the balance up from that transaction. On a renewed term that deposit + // is BEFORE the period range and would be missed, leaving the term to earn interest on nothing, + // so the term's own principal seeds the opening balance instead. + Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount()) + : Money.zero(currency); final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType); final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure); @@ -686,6 +695,60 @@ public class FixedDepositAccount extends SavingsAccount { return this.accountTermAndPreClosure.maturityAmount(); } + /** + * The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to + * this account's own number when it has never been renewed, so callers always get a usable FD number. + */ + public String fdAccountNumber() { + final String carried = this.accountTermAndPreClosure.getFdAccountNumber(); + return carried != null ? carried : getAccountNumber(); + } + + /** 0 for the originally opened term, 1 for the first renewal, and so on. */ + public Integer renewalCycleNo() { + return this.accountTermAndPreClosure.getRenewalCycleNo(); + } + + public Integer depositPeriod() { + return this.accountTermAndPreClosure.depositPeriod(); + } + + public Integer depositPeriodFrequency() { + return this.accountTermAndPreClosure.depositPeriodFrequency(); + } + + public Long reinvestedFromSavingsId() { + return this.accountTermAndPreClosure.getReinvestedFromSavingsId(); + } + + /** + * The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's + * chart identity can be archived rather than lost. + */ + public String currentChartName() { + return this.chart == null ? null : this.chart.chartFields().getName(); + } + + /** + * The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached + * from the chart's own persistence lifecycle. + *

+ * Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full + * rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal + * history records -- rather than being silently deleted when the new term's slabs replace it. + */ + public List currentChartSlabFields() { + return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots(); + } + + /** + * This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when + * the product is not a Fixed Deposit product or has no type set. + */ + public String fdType() { + return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null; + } + private Money totalInterestPosted() { Money interestPostedToDate = Money.zero(this.currency); for (final SavingsAccountTransaction transaction : this.transactions) { @@ -715,11 +778,26 @@ public class FixedDepositAccount extends SavingsAccount { return actualChanges; } + /** + * The start of the term currently running, which is what the deposit's tenure and rate must be measured from. + *

+ * For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances + * startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS + * term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the + * renewal rather than from the original activation. + */ private LocalDate depositStartDate() { - // TODO: Support to add deposit start date which can be a date after - // account activation date. - final LocalDate depositStartDate = accountSubmittedOrActivationDate(); - return depositStartDate; + final LocalDate currentTermStartDate = getStartInterestCalculationDate(); + return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate(); + } + + /** + * True once this FD has been renewed at least once, i.e. the term now running started later than the account did. + * Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term. + */ + private boolean isRenewedTerm() { + final LocalDate termStart = getStartInterestCalculationDate(); + return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate()); } private LocalDate interestCalculatedUpto() { @@ -814,20 +892,129 @@ public class FixedDepositAccount extends SavingsAccount { return this.accountTermAndPreClosure.getTransferToSavingsAccountId(); } + /** + * Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it + * renews. No new account is opened and no money moves: the balance simply carries into the next term. + *

+ * What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines + * interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the + * posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes + * {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it + * stands today, so a renewal is priced on current terms rather than the ones frozen at account opening. + *

+ * The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal + * history so each term's own figures remain reportable. + * + * @param renewedAmount + * what carries into the new term: the full maturity amount, or the principal only + * @param renewedOn + * the renewal date, which becomes the new term's start + * @param currentSeniorCitizenRate + * the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to + * keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the + * renewal never turns that flag on or off. + * @param baseChartFields + * identity of the CBS base rate version being applied, or null to keep the existing chart's + * @param baseChartSlabs + * the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the + * rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls + * back to the product's own chart, so a product with no base rate version still renews on something. + */ + public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate, + final InterestRateChartFields baseChartFields, final Collection baseChartSlabs) { + + final SavingsProduct product = this.product; + + // Confines interest posting to the new term. Set before recomputing maturity, since maturity is + // measured from the current term's start. + setStartInterestCalculationDate(renewedOn); + + this.accountTermAndPreClosure.updateDepositAmount(renewedAmount); + this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1); + // Pin the chain number now that this account has renewed at least once, so a non-zero cycle number + // is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number + // if there is one and to this account's own otherwise, which is the same value every reader derives. + this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber()); + + // Re-price off the CURRENT product, exactly as opening a fresh deposit today would. + this.nominalAnnualInterestRate = product.nominalAnnualInterestRate(); + this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue(); + this.interestPostingPeriodType = product.interestPostingPeriodType().getValue(); + this.interestCalculationType = product.interestCalculationType().getValue(); + this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue(); + this.withHoldTax = product.withHoldTax(); + + // Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy + // close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would + // carry the lock-in window computed at the original activation -- long expired -- so a product with + // a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only. + final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType(); + this.lockinPeriodFrequency = product.lockinPeriodFrequency(); + this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null + : renewedLockinPeriodFrequencyType.getValue(); + this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null + : calculateDateAccountIsLockedUntil(renewedOn); + + // The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on + // the Base Interest Rates screen, per FD type, with its own active version. That master is what + // the bank publishes and what the account-opening wizard already shows, so pricing a renewal off + // anything else would put the deposit on rates nobody chose. + if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) { + this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs); + } else if (this.chart != null) { + // No active base version for this FD type: fall back to the product's own chart so the + // renewal still gets current product rates rather than the ones frozen at opening. + // activeChart, not applicableChart -- the latter treats a chart with no end date as valid only + // up to today's business date, so a renewal dated in the future would find nothing. + final InterestRateChart productChart = product instanceof FixedDepositProduct + ? ((FixedDepositProduct) product).activeChart(renewedOn) + : null; + if (productChart != null) { + this.chart.refreshFrom(productChart); + } + } + + // The senior-citizen premium is stored per account and takes priority over the chart in + // getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a + // senior-citizen deposit at the rate captured when the account was opened. Only overwritten when + // the caller found a currently published rate -- otherwise the existing one stands, so a missing + // rate version can never silently drop the depositor's premium. + if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) { + setSeniorCitizenInterestRate(currentSeniorCitizenRate); + } + + // The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before + // this ran, so the new term has to put it back. + this.status = SavingsAccountStatusType.ACTIVE.getValue(); + setClosedOnDate(null); + } + public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) { final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount); final SavingsProduct product = this.product; - final InterestRateChart productChart = product.applicableChart(getClosedOnDate()); + // Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's + // business date, and the most recently effective chart wins when more than one covers the date. + // Falls back to applicableChart so a product whose charts have no usable from-date still resolves + // as it did before. + InterestRateChart productChart = product instanceof FixedDepositProduct + ? ((FixedDepositProduct) product).activeChart(getClosedOnDate()) + : null; + if (productChart == null) { + productChart = product.applicableChart(getClosedOnDate()); + } final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart); 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); + // Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's + // frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a + // renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting + // was in force when the ORIGINAL account was opened, silently ignoring every product edit made + // since (e.g. Daily->Quarterly compounding, or a lock-in added later). + final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType(); + final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType(); + final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType(); + final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType(); final BigDecimal minRequiredOpeningBalance = null; // Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses // for a brand-new account -- so a product with no chart slab covering this tenure/amount @@ -836,11 +1023,11 @@ public class FixedDepositAccount extends SavingsAccount { // original account was opened) whenever a matching slab exists. final BigDecimal interestRate = product.nominalAnnualInterestRate(); final Set savingsAccountCharges = null; - final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); - final Integer lockinPeriodFrequency = this.lockinPeriodFrequency; + final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType(); + final Integer lockinPeriodFrequency = product.lockinPeriodFrequency(); final boolean withdrawalFeeApplicableForTransfer = false; final String accountNumber = null; - final boolean withHoldTax = this.withHoldTax; + final boolean withHoldTax = product.withHoldTax(); final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType, postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency, @@ -859,6 +1046,21 @@ public class FixedDepositAccount extends SavingsAccount { // each other instead of the link disappearing the moment the old one closes. newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId()); + // Carry the ORIGINAL account's number forward across every renewal, so the customer keeps + // seeing one recognisable FD number even though each term is its own account/ledger row. + final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null + ? this.accountTermAndPreClosure.getFdAccountNumber() + : this.getAccountNumber(); + newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber); + + // Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048". + newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1); + + // Without this the renewed deposit has no maturity instruction, so it would mature once more and + // then sit idle rather than rolling over again as the customer asked. Done here rather than in + // the shared copy(), which Recurring Deposit renewal also uses. + newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure); + return reInvestedAccount; } @@ -918,6 +1120,52 @@ public class FixedDepositAccount extends SavingsAccount { return SavingsAccountStatusType.MATURED.getValue().equals(this.status); } + /** + * Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the + * Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going + * through {@link #prematureClosure}, since those branches keep the account open on the same term instead of + * closing it. + */ + public boolean isLockedOnDate(final LocalDate transactionDate) { + return isAccountLocked(transactionDate); + } + + /** + * The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment + * that renews the deposit instead of closing it and so never reaches either of those methods. + *

+ * Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three + * matter more here than they do on a closure, because a renewal turns this date into the new term's interest + * pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting + * periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent. + */ + public void validateRenewalDate(final LocalDate renewedOn) { + final List dataValidationErrors = new ArrayList<>(); + final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) + .resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction); + + if (DateUtils.isAfterBusinessDate(renewedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("cannot.be.a.future.date"); + } + if (DateUtils.isBefore(renewedOn, getActivationDate())) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("must.be.after.activation.date"); + } + final List savingsAccountTransactions = retrieveListOfTransactions(); + if (!savingsAccountTransactions.isEmpty()) { + final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1); + if (lastTransaction.isAfter(renewedOn)) { + baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn) + .failWithCode("must.be.after.last.transaction.date"); + } + } + + if (!dataValidationErrors.isEmpty()) { + throw new PlatformApiDataValidationException(dataValidationErrors); + } + } + public void setClosedOnDate(final LocalDate closedOnDate) { this.closedOnDate = closedOnDate; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java new file mode 100644 index 000000000..bd72dd9e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistory.java @@ -0,0 +1,99 @@ +/** + * 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 + */ +package org.apache.fineract.portfolio.savings.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's + * {@link FixedDepositRenewalHistory} row. + *

+ * {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in + * place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not + * the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during + * term 2", not just "what rate applied". + */ +@Entity +@Table(name = "m_fd_renewal_chart_slab_history") +@Getter +@NoArgsConstructor +public class FixedDepositRenewalChartSlabHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "fd_renewal_history_id", nullable = false) + private Long fdRenewalHistoryId; + + @Column(name = "chart_name", length = 100) + private String chartName; + + @Column(name = "description", length = 200) + private String description; + + @Column(name = "period_type_enum") + private Integer periodTypeEnum; + + @Column(name = "from_period") + private Integer fromPeriod; + + @Column(name = "to_period") + private Integer toPeriod; + + @Column(name = "amount_range_from", scale = 6, precision = 19) + private BigDecimal amountRangeFrom; + + @Column(name = "amount_range_to", scale = 6, precision = 19) + private BigDecimal amountRangeTo; + + @Column(name = "annual_interest_rate", scale = 6, precision = 19) + private BigDecimal annualInterestRate; + + @Column(name = "currency_code", length = 3) + private String currencyCode; + + /** + * The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the + * CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was + * priced off the product's own chart (which has no senior-citizen concept) or predates this column. + */ + @Column(name = "senior_citizen_rate", scale = 6, precision = 19) + private BigDecimal seniorCitizenRate; + + public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName, + final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod, + final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate, + final String currencyCode, final BigDecimal seniorCitizenRate) { + final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory(); + slab.fdRenewalHistoryId = fdRenewalHistoryId; + slab.chartName = chartName; + slab.description = description; + slab.periodTypeEnum = periodTypeEnum; + slab.fromPeriod = fromPeriod; + slab.toPeriod = toPeriod; + slab.amountRangeFrom = amountRangeFrom; + slab.amountRangeTo = amountRangeTo; + slab.annualInterestRate = annualInterestRate; + slab.currencyCode = currencyCode; + slab.seniorCitizenRate = seniorCitizenRate; + return slab; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java new file mode 100644 index 000000000..8669f3819 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalChartSlabHistoryRepository.java @@ -0,0 +1,21 @@ +/** + * 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 + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository { + + /** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */ + List findByFdRenewalHistoryId(Long fdRenewalHistoryId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java new file mode 100644 index 000000000..e63bb9817 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistory.java @@ -0,0 +1,158 @@ +/** + * 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 + */ +package org.apache.fineract.portfolio.savings.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * One row per term of one logical Fixed Deposit, recording the renewal event that started it. + *

+ * Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable + * ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is + * what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is + * what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried + * forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a + * user, and by whom. + */ +@Entity +@Table(name = "m_fd_renewal_history") +@Getter +@NoArgsConstructor +public class FixedDepositRenewalHistory { + + /** Renewal ran automatically when the maturity job processed the account's maturity instruction. */ + public static final String RENEWAL_TYPE_AUTO = "AUTO"; + /** A user closed the account with a re-invest instruction. */ + public static final String RENEWAL_TYPE_MANUAL = "MANUAL"; + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_MATURED = "MATURED"; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** The stable customer-facing FD number, shared by every term in the chain. */ + @Column(name = "fd_account_number", length = 20, nullable = false) + private String fdAccountNumber; + + /** The account the customer originally opened, i.e. the anchor of the chain. */ + @Column(name = "original_savings_account_id", nullable = false) + private Long originalSavingsAccountId; + + /** The deposit instance this term runs on. */ + @Column(name = "savings_account_id", nullable = false) + private Long savingsAccountId; + + /** The term this one was renewed from; null on the chain's first row. */ + @Column(name = "previous_savings_account_id") + private Long previousSavingsAccountId; + + @Column(name = "previous_renewal_id") + private Long previousRenewalId; + + /** 0 for the originally opened term, 1 for the first renewal, and so on. */ + @Column(name = "renewal_no", nullable = false) + private Integer renewalNo; + + @Column(name = "previous_principal", scale = 6, precision = 19) + private BigDecimal previousPrincipal; + + @Column(name = "interest_earned", scale = 6, precision = 19) + private BigDecimal interestEarned; + + @Column(name = "maturity_amount", scale = 6, precision = 19) + private BigDecimal maturityAmount; + + /** What actually carried into this term: the full maturity amount, or principal only. */ + @Column(name = "renewed_amount", scale = 6, precision = 19) + private BigDecimal renewedAmount; + + @Column(name = "interest_rate", scale = 6, precision = 19) + private BigDecimal interestRate; + + @Column(name = "start_date") + private LocalDate startDate; + + @Column(name = "maturity_date") + private LocalDate maturityDate; + + @Column(name = "tenure") + private Integer tenure; + + @Column(name = "tenure_frequency_enum") + private Integer tenureFrequencyEnum; + + @Column(name = "renewal_type", length = 10) + private String renewalType; + + @Column(name = "renewal_status", length = 20) + private String renewalStatus; + + @Column(name = "renewed_on") + private LocalDate renewedOn; + + @Column(name = "created_by_id") + private Long createdById; + + public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId, + final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo, + final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount, + final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate, + final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn, + final Long createdById) { + final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory(); + history.fdAccountNumber = fdAccountNumber; + history.originalSavingsAccountId = originalSavingsAccountId; + history.savingsAccountId = savingsAccountId; + history.previousSavingsAccountId = previousSavingsAccountId; + history.previousRenewalId = previousRenewalId; + history.renewalNo = renewalNo; + history.previousPrincipal = previousPrincipal; + history.interestEarned = interestEarned; + history.maturityAmount = maturityAmount; + history.renewedAmount = renewedAmount; + history.interestRate = interestRate; + history.startDate = startDate; + history.maturityDate = maturityDate; + history.tenure = tenure; + history.tenureFrequencyEnum = tenureFrequencyEnum; + history.renewalType = renewalType; + history.renewalStatus = STATUS_ACTIVE; + history.renewedOn = renewedOn; + history.createdById = createdById; + return history; + } + + /** Called on the previous term's row once its successor takes over. */ + public void markMatured() { + this.renewalStatus = STATUS_MATURED; + } + + /** + * Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's + * own row so each term reports its own outcome rather than that of its predecessor. + */ + public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) { + this.interestEarned = interestEarned; + this.maturityAmount = maturityAmount; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java new file mode 100644 index 000000000..cb9579ee5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositRenewalHistoryRepository.java @@ -0,0 +1,30 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + */ +package org.apache.fineract.portfolio.savings.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface FixedDepositRenewalHistoryRepository extends JpaRepository { + + /** One FD's whole history, oldest term first. */ + List findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber); + + /** + * An account's terms, newest first. + *

+ * Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at + * that one account, so callers wanting the term currently running take the first element. Empty for an account that + * pre-dates this table or was never renewed. + */ + List findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java new file mode 100644 index 000000000..6fd3ea504 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/handler/PaymentFixedDepositAccountCommandHandler.java @@ -0,0 +1,46 @@ +/** + * 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 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.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT") +public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler { + + private final DepositAccountWritePlatformService depositAccountWritePlatformService; + + @Autowired + public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) { + this.depositAccountWritePlatformService = depositAccountWritePlatformService; + } + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java index bdd07b3af..87cf93a38 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountInterestRateChartReadPlatformServiceImpl.java @@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D .append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ") .append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ") .append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ") + .append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ") .append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ") .append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ") .append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ") @@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom"); final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo"); final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate"); + final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate"); // currency Slabs final String currencyCode = rs.getString("currencyCode"); @@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D currencyNameCode); return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom, - amountRangeTo, annualInterestRate, currency); + amountRangeTo, annualInterestRate, currency, seniorCitizenRate); } } 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 21841f4bd..d6d943485 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 @@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService { DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId); + /** + * As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries + * PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it. + */ + DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId); + DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId); Collection retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId); 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 130b1fb60..421ed87ad 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 @@ -367,6 +367,121 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead if (fromId != null || intoId != null) { result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo); } + + // The number to actually show as "the FD account number" -- the original account's account_no + // if this account has been through a renewal, otherwise this account's own account_no. + final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure " + + "where savings_account_id = ?"; + final java.util.List> termRows = this.jdbcTemplate.queryForList(termSql, accountId); + final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number"); + final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no"); + final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo(); + result.setFdAccountNumber(rootFdAccountNumber); + result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue()); + result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber)); + } + + /** + * Every term of one logical FD, oldest first. + *

+ * Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it + * matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by + * joining account rows, which cannot express any of that. The account row is joined in for its number and live + * status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as + * simply a non-empty list. + */ + private java.util.List> retrieveRenewalHistory(final String fdAccountNumber) { + // Every row states its own term's figures, so this is a straight read: interest_earned and + // maturity_amount are filled in when that term closes, and stay null on the term still running, + // which the caller renders as its projected maturity instead. + final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, " + + "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, " + + "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, " + + "h.tenure_frequency_enum as tenureFrequencyEnum, " + + "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, " + + "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, " + + "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h " + + "join m_savings_account sa on sa.id = h.savings_account_id " + + "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id " + + "where h.fd_account_number = ? " + "order by h.renewal_no asc"; + final java.util.List> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber); + // Any row here is a renewal event that was actually recorded, so even one is worth returning -- + // discarding a lone row used to hide a genuine renewal (and its interest figure) behind the + // account-derived fallback, which then returned nothing at all for it. + if (!rows.isEmpty()) { + attachArchivedChartSlabs(rows); + return rows; + } + // Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to + // reconstructing what the account rows alone can tell us. The per-term interest/renewal-type + // columns are simply absent for those, which the UI renders as blanks rather than wrong values. + return retrieveRenewalHistoryFromAccounts(fdAccountNumber); + } + + /** + * Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row. + *

+ * A term's chart is only archived when it closes via an in-place renewal (see + * {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before + * that archiving existed simply get an empty list here -- callers should fall back to the account's live + * accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since + * this runs once per FD account view. + */ + private void attachArchivedChartSlabs(final java.util.List> rows) { + final java.util.List historyIds = new java.util.ArrayList<>(); + for (final java.util.Map row : rows) { + final Object id = row.get("historyId"); + if (id != null) { + historyIds.add(((Number) id).longValue()); + } + } + if (historyIds.isEmpty()) { + return; + } + final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?")); + final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, " + + "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, " + + "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate " + + "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") " + + "order by from_period asc"; + final java.util.List> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray()); + + final java.util.Map>> slabsByHistoryId = new java.util.HashMap<>(); + for (final java.util.Map slabRow : slabRows) { + final Long historyId = ((Number) slabRow.remove("historyId")).longValue(); + slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow); + } + + for (final java.util.Map row : rows) { + final Object id = row.get("historyId"); + final Long historyId = id == null ? null : ((Number) id).longValue(); + row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList())); + } + } + + private java.util.List> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) { + // Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has + // to know which path produced the rows. What the account rows genuinely cannot tell us -- each + // term's interest earned, and whether the rollover was automatic or done by staff -- is returned as + // an explicit null rather than omitted, so those render as blanks instead of the previous behaviour + // where a missing renewalStatus made every term, including the running one, look matured. + final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, " + + "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, " + + "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, " + + "t.maturity_date as maturityDate, t.deposit_period as tenure, " + + "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, " + + "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, " + + "sa.status_enum as statusEnum, null as interestEarned, " + + "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, " + + "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount " + + "from m_savings_account sa " + + "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id " + + "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc"; + final java.util.List> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber, + fdAccountNumber); + // A single row here means this FD simply never renewed -- one account, one term -- so there is no + // history to show. Unlike the primary path, where even one recorded row is a real renewal event. + return rows.size() > 1 ? rows : java.util.Collections.emptyList(); } private java.util.List> retrieveJointHolders(final Long accountId) { @@ -389,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead }, accountId); } + /** + * Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the + * Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it + * needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for + * it and would fall through to paying the whole balance out in cash. + */ @Override public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) { + final boolean includePartialReinvest = false; + return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest); + } + + /** + * The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that + * implements it. + */ + @Override + public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) { + final boolean includePartialReinvest = true; + return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest); + } + + private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId, + final boolean includePartialReinvest) { try { this.context.authenticatedUser(); @@ -407,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue()); Collection onAccountClosureOptions = SavingsEnumerations - .depositAccountOnClosureType(DepositAccountOnClosureType.values()); + .depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values()) + .filter(type -> includePartialReinvest || !type.isPartialReinvest()) + .toArray(DepositAccountOnClosureType[]::new)); final Collection paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes(); final Collection savingsAccountDatas = this.savingsAccountReadPlatformService .retrieveActiveForLookup(account.getClientId(), 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 fe4c4a6c8..dd60c181e 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 @@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; import org.apache.fineract.infrastructure.core.domain.ExternalId; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException; import org.apache.fineract.infrastructure.core.service.DateUtils; @@ -912,6 +913,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo } + @Override + public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) { + final AppUser user = this.context.authenticatedUser(); + + this.depositAccountTransactionDataValidator.validatePayment(command); + + final Map changes = new LinkedHashMap<>(); + final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes); + + // Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close + // (Active only) -- but it must still be one of those two. Without this, a closed or + // pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to + // ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it. + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId, + DepositAccountType.FIXED_DEPOSIT); + if (!account.isActive() && !account.isMatured()) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured", + "Payment can only be processed on an active or matured fixed deposit account.", savingsId); + } + checkClientOrGroupActive(account); + + this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes); + + // Reinvest Principal Only opens a second account and the domain method withdrew the principal out + // of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually + // land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path. + if (changes.get("reinvestedDepositId") != null) { + final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId"); + final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler + .assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT); + Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount()); + final Money activationChargeAmount = getActivationCharge(reinvestAccount); + if (activationChargeAmount.isGreaterThanZero()) { + payActivationCharge(reinvestAccount); + amountForDeposit = amountForDeposit.plus(activationChargeAmount); + } + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale()); + final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName); + final boolean isInternalTransferLeg = true; + this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null, + isInternalTransferLeg); + } + + final String noteText = command.stringValueOfParameterNamed("note"); + if (StringUtils.isNotBlank(noteText)) { + final Note note = Note.savingNote(account, noteText); + changes.put("note", noteText); + this.noteRepository.save(note); + } + + 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(); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 22f8bf57a..ebdcdf988 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -538,4 +538,12 @@ + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml new file mode 100644 index 000000000..1df834745 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0471_add_fd_account_number_to_deposit_term.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml new file mode 100644 index 000000000..5be47a3ed --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0472_add_renewal_cycle_no_to_deposit_term.xml @@ -0,0 +1,23 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml new file mode 100644 index 000000000..da8f9f5bc --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0473_create_fd_renewal_history.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml new file mode 100644 index 000000000..a57c9ba9c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0474_add_is_active_to_interest_rate_chart.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + end_date IS NOT NULL AND end_date < CURRENT_DATE + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml new file mode 100644 index 000000000..ae7ab6e95 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0475_create_fd_renewal_chart_slab_history.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml new file mode 100644 index 000000000..598873346 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml new file mode 100644 index 000000000..950d0f94b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml new file mode 100644 index 000000000..1c10cc42f --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0478_add_fd_payment_permission.xml @@ -0,0 +1,29 @@ + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT', 'FIXEDDEPOSITACCOUNT', 'PAYMENT', true + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT'); + + + + + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) + SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER', 'FIXEDDEPOSITACCOUNT', 'PAYMENT_CHECKER', false + WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER'); + + + + 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 bac91df40..be62adada 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 @@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.interestratechart.InterestRateChartS import com.google.gson.JsonArray; import com.google.gson.JsonObject; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -67,6 +68,28 @@ public class InterestRateChart extends AbstractPersistableCustom { @OneToMany(mappedBy = "interestRateChart", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private Set chartSlabs = new HashSet<>(); + /** + * Whether this is a rate version the product currently offers. + *

+ * Kept as an explicit flag rather than inferred from the from/end dates, so retiring a rate version is a deliberate + * act and the dates stay purely the historical record. New charts start active. + */ + @Column(name = "is_active", nullable = false) + private boolean active = true; + + public boolean isActive() { + return this.active; + } + + /** Retires this rate version, so it stops being picked for new and renewing deposits. */ + public void deactivate() { + this.active = false; + } + + public void activate() { + this.active = true; + } + protected InterestRateChart() { // } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java index e1a78a96d..433729acb 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/interestratechart/domain/InterestRateChartFields.java @@ -123,6 +123,10 @@ public class InterestRateChartFields { return compare != null && DateUtils.isAfter(getFromDate(), compare); } + public String getName() { + return this.name; + } + public LocalDate getFromDate() { return this.fromDate; } 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 2e97e34e9..50b891708 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 @@ -177,6 +177,10 @@ public class InterestRateChartSlabFields { return isGreater; } + public String description() { + return this.description; + } + public Integer periodType() { return this.periodType; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java index 42ab9cb39..fbb49e195 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountInterestRateChartSlabData.java @@ -43,6 +43,13 @@ public final class DepositAccountInterestRateChartSlabData { private final BigDecimal annualInterestRate; private final CurrencyData currency; + /** + * The senior-citizen premium published on the CBS Base Interest Rate master for this tenure band, carried onto the + * account's own chart slab so it survives independently of whatever the master shows today. Null for a slab priced + * off the product's own chart (which has no senior-citizen concept) or for any Savings/RD slab. + */ + private final BigDecimal seniorCitizenRate; + // associations private Collection incentives; @@ -66,7 +73,7 @@ public final class DepositAccountInterestRateChartSlabData { public static DepositAccountInterestRateChartSlabData instance(final Long id, final String description, final EnumOptionData periodType, final Integer fromPeriod, final Integer toPeriod, final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, - final BigDecimal annualInterestRate, final CurrencyData currency) { + final BigDecimal annualInterestRate, final CurrencyData currency, final BigDecimal seniorCitizenRate) { final Collection periodTypes = null; final Collection entityTypeOptions = null; final Collection attributeNameOptions = null; @@ -78,7 +85,8 @@ public final class DepositAccountInterestRateChartSlabData { final Collection incentives = null; return new DepositAccountInterestRateChartSlabData(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom, amountRangeTo, annualInterestRate, incentives, currency, periodTypes, entityTypeOptions, attributeNameOptions, - conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions); + conditionTypeOptions, incentiveTypeOptions, genderOptions, clientTypeOptions, clientClassificationOptions, + seniorCitizenRate); } public static DepositAccountInterestRateChartSlabData from(final InterestRateChartSlabData chartSlabData) { @@ -90,12 +98,14 @@ public final class DepositAccountInterestRateChartSlabData { fromProdIncentives.add(DepositAccountInterestIncentiveData.from(incentive)); } } + // the product-level chart this comes from has no senior-citizen concept at all + final BigDecimal seniorCitizenRate = null; return new DepositAccountInterestRateChartSlabData(id, chartSlabData.description(), chartSlabData.periodType(), chartSlabData.fromPeriod(), chartSlabData.toPeriod(), chartSlabData.amountRangeFrom(), chartSlabData.amountRangeTo(), chartSlabData.annualInterestRate(), fromProdIncentives, chartSlabData.currency(), chartSlabData.periodTypes(), chartSlabData.entityTypeOptions(), chartSlabData.attributeNameOptions(), chartSlabData.conditionTypeOptions(), chartSlabData.incentiveTypeOptions(), chartSlabData.genderOptions(), chartSlabData.clientTypeOptions(), - chartSlabData.clientClassificationOptions()); + chartSlabData.clientClassificationOptions(), seniorCitizenRate); } public static DepositAccountInterestRateChartSlabData withTemplate(final DepositAccountInterestRateChartSlabData chartSlab, @@ -106,7 +116,7 @@ public final class DepositAccountInterestRateChartSlabData { return new DepositAccountInterestRateChartSlabData(chartSlab.id, chartSlab.description, chartSlab.periodType, chartSlab.fromPeriod, chartSlab.toPeriod, chartSlab.amountRangeFrom, chartSlab.amountRangeTo, chartSlab.annualInterestRate, chartSlab.incentives, chartSlab.currency, periodTypes, entityTypeOptions, attributeNameOptions, conditionTypeOptions, incentiveTypeOptions, - genderOptions, clientTypeOptions, clientClassificationOptions); + genderOptions, clientTypeOptions, clientClassificationOptions, chartSlab.seniorCitizenRate); } private DepositAccountInterestRateChartSlabData(final Long id, final String description, final EnumOptionData periodType, @@ -115,7 +125,8 @@ public final class DepositAccountInterestRateChartSlabData { final CurrencyData currency, final Collection periodTypes, final Collection entityTypeOptions, final Collection attributeNameOptions, final Collection conditionTypeOptions, final Collection incentiveTypeOptions, final Collection genderOptions, - final Collection clientTypeOptions, final Collection clientClassificationOptions) { + final Collection clientTypeOptions, final Collection clientClassificationOptions, + final BigDecimal seniorCitizenRate) { this.id = id; this.description = description; this.periodType = periodType; @@ -125,6 +136,7 @@ public final class DepositAccountInterestRateChartSlabData { this.amountRangeTo = amountRangeTo; this.annualInterestRate = annualInterestRate; this.currency = currency; + this.seniorCitizenRate = seniorCitizenRate; this.periodTypes = periodTypes; this.incentives = incentivesData; this.attributeNameOptions = attributeNameOptions; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java index d67d12baf..c9fd58529 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositAccountData.java @@ -155,6 +155,33 @@ public final class FixedDepositAccountData extends DepositAccountData { this.reinvestedIntoAccountNo = reinvestedIntoAccountNo; } + // The account number the customer should keep seeing across renewals -- the ORIGINAL account's + // account_no, carried forward onto every account created by reinvesting it. Falls back to this + // account's own accountNo when it has never been reinvested (see DepositAccountReadPlatformServiceImpl). + private String fdAccountNumber; + + public void setFdAccountNumber(final String fdAccountNumber) { + this.fdAccountNumber = fdAccountNumber; + } + + /** Which renewal cycle of that FD this account is: 0 = originally opened, 1 = first renewal, etc. */ + private Integer renewalCycleNo; + + public void setRenewalCycleNo(final Integer renewalCycleNo) { + this.renewalCycleNo = renewalCycleNo; + } + + /** + * Every term of this FD, oldest first: one entry per account in the renewal chain, each carrying that term's own + * account number, cycle number, dates, principal and maturity amount. Lets the account view and the deposit + * certificate present "one FD, many terms" without the caller having to walk the chain itself. + */ + private java.util.List> renewalHistory; + + public void setRenewalHistory(final java.util.List> renewalHistory) { + this.renewalHistory = renewalHistory; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; @@ -350,6 +377,9 @@ public final class FixedDepositAccountData extends DepositAccountData { this.reinvestedFromAccountNo = src.reinvestedFromAccountNo; this.reinvestedIntoAccountId = src.reinvestedIntoAccountId; this.reinvestedIntoAccountNo = src.reinvestedIntoAccountNo; + this.fdAccountNumber = src.fdAccountNumber; + this.renewalCycleNo = src.renewalCycleNo; + this.renewalHistory = src.renewalHistory; this.subStatus = src.subStatus; this.reasonForBlock = src.reasonForBlock; this.lastActiveTransactionDate = src.lastActiveTransactionDate; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java index 4e2e1389f..e2007c80e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChart.java @@ -28,12 +28,16 @@ import jakarta.persistence.OneToOne; import jakarta.persistence.Table; import java.math.BigDecimal; import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collection; import java.util.HashSet; +import java.util.List; import java.util.Set; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; import org.apache.fineract.portfolio.client.domain.Client; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields; +import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields; import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlab; import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculation; import org.apache.fineract.portfolio.interestratechart.incentive.AttributeIncentiveCalculationFactory; @@ -156,4 +160,86 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom + * Used when an FD renews on the same account: the new term must be priced off whatever the product offers on the + * renewal date, not the rates frozen when the account was first opened. This has to mutate the existing row rather + * than swap in a new chart, because the account-to-chart mapping is one-to-one on the chart's savings_account_id, so + * a second row for the same account would break loading it. + */ + public void refreshFrom(final InterestRateChart productChart) { + this.chartFields = productChart.chartFields(); + // orphanRemoval on chartSlabs deletes the superseded slab rows. + setOfChartSlabs().clear(); + for (final InterestRateChartSlab productSlab : productChart.setOfChartSlabs()) { + this.chartSlabs.add(DepositAccountInterestRateChartSlabs.from(productSlab, this)); + } + } + + /** + * A slab's rate-by-tenure fields plus its senior-citizen premium, travelling together. + *

+ * The premium isn't part of {@link InterestRateChartSlabFields} because that embeddable is shared with the + * product-level chart slab table, which has no CBS senior-citizen concept -- so it rides alongside as a plain pair + * rather than being added to the shared type. + */ + public static final class SlabSnapshot { + + private final InterestRateChartSlabFields fields; + private final BigDecimal seniorCitizenRate; + + public SlabSnapshot(final InterestRateChartSlabFields fields, final BigDecimal seniorCitizenRate) { + this.fields = fields; + this.seniorCitizenRate = seniorCitizenRate; + } + + public InterestRateChartSlabFields getFields() { + return this.fields; + } + + public BigDecimal getSeniorCitizenRate() { + return this.seniorCitizenRate; + } + } + + /** + * This chart's slabs as they stand right now, each paired with its senior-citizen premium. Read before + * {@link #refreshFromSlabSnapshots} overwrites them, so a renewing FD can archive the term that is closing rather than + * losing its rate card the moment the new term's rates replace it. + */ + public List currentSlabSnapshots() { + final List snapshots = new ArrayList<>(); + for (final DepositAccountInterestRateChartSlabs slab : setOfChartSlabs()) { + snapshots.add(new SlabSnapshot(slab.slabFields(), slab.getSeniorCitizenRate())); + } + return snapshots; + } + + /** + * Re-snapshots this account's chart from an explicit set of slabs (with their senior-citizen premiums), in place. + *

+ * Used when the rates come from the CBS base interest-rate master rather than from the product's own chart -- that + * master is the rate card staff maintain and see on screen, so a renewing deposit priced off anything else would + * disagree with what the bank publishes. Mutates the existing row for the same reason + * {@link #refreshFrom(InterestRateChart)} does: the account-to-chart mapping is one-to-one on the chart's + * savings_account_id, so a second row for the same account would break loading it. + *

+ * Does nothing when handed no slabs, leaving the account on the chart it already has, so a missing or empty rate + * version can never leave a deposit with no rates at all. + */ + public void refreshFromSlabSnapshots(final InterestRateChartFields chartFields, final Collection snapshots) { + if (snapshots == null || snapshots.isEmpty()) { + return; + } + if (chartFields != null) { + this.chartFields = chartFields; + } + setOfChartSlabs().clear(); + for (final SlabSnapshot snapshot : snapshots) { + this.chartSlabs + .add(DepositAccountInterestRateChartSlabs.fromFields(snapshot.getFields(), this, snapshot.getSeniorCitizenRate())); + } + } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java index 119185a99..0a3f5d588 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountInterestRateChartSlabs.java @@ -19,6 +19,7 @@ package org.apache.fineract.portfolio.savings.domain; import jakarta.persistence.CascadeType; +import jakarta.persistence.Column; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.FetchType; @@ -26,6 +27,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import jakarta.persistence.Table; +import java.math.BigDecimal; import java.util.HashSet; import java.util.Set; import org.apache.fineract.infrastructure.core.domain.AbstractPersistableCustom; @@ -47,6 +49,16 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus @OneToMany(mappedBy = "depositAccountInterestRateChartSlabs", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.EAGER) private Set interestIncentives = new HashSet<>(); + /** + * The senior-citizen premium for this slab's tenure band, as published on the CBS Base Interest Rates screen at the + * time this chart was set. Not part of {@link InterestRateChartSlabFields} because that embeddable is shared with the + * product-level {@code m_interest_rate_slab} table, which has no CBS senior-citizen concept; kept as a column of its + * own here instead, on the account-level table only. Null for a slab copied from a product chart (which never carries + * one) or for a slab that predates this column. + */ + @Column(name = "senior_citizen_rate", scale = 6, precision = 19) + private BigDecimal seniorCitizenRate; + protected DepositAccountInterestRateChartSlabs() { // } @@ -59,6 +71,10 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus this.interestIncentives = interestIncentives; } + public BigDecimal getSeniorCitizenRate() { + return this.seniorCitizenRate; + } + public void setDepositAccountInterestRateChart(DepositAccountInterestRateChart depositAccountInterestRateChart) { this.depositAccountInterestRateChart = depositAccountInterestRateChart; } @@ -71,6 +87,19 @@ public class DepositAccountInterestRateChartSlabs extends AbstractPersistableCus return this.slabFields; } + /** + * Builds a slab straight from its field values, for a chart taken from a source that is not a product + * {@link InterestRateChartSlab} -- specifically the CBS base interest-rate master, which is the bank's published rate + * card and carries no incentive rules. + */ + public static DepositAccountInterestRateChartSlabs fromFields(final InterestRateChartSlabFields slabFields, + final DepositAccountInterestRateChart depositAccountInterestRateChart, final BigDecimal seniorCitizenRate) { + final DepositAccountInterestRateChartSlabs slab = new DepositAccountInterestRateChartSlabs(slabFields, + depositAccountInterestRateChart, new HashSet<>()); + slab.seniorCitizenRate = seniorCitizenRate; + return slab; + } + public static DepositAccountInterestRateChartSlabs from(InterestRateChartSlab interestRateChartSlab, DepositAccountInterestRateChart depositAccountInterestRateChart) { InterestRateChartSlabFields slabFields = interestRateChartSlab.slabFields(); 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 37b3db921..9f15e9102 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 @@ -104,6 +104,34 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom + * These describe what the customer asked the deposit to do at every maturity, not a one-off choice for a single term, + * so a renewed deposit that lost them would mature once more and then sit idle instead of rolling over again. + */ + public void carryForwardStandingInstructionsFrom(final DepositAccountTermAndPreClosure previousTerm) { + this.onAccountClosureType = previousTerm.onAccountClosureType; + this.transferToSavingsAccountId = previousTerm.transferToSavingsAccountId; + this.transferInterestToLinkedAccount = previousTerm.transferInterestToLinkedAccount; + } + public boolean isReinvestOnClosure() { return DepositAccountOnClosureType.fromInt(this.onAccountClosureType).isReinvest(); } @@ -332,6 +373,9 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom + * Differs from {@link #applicableChart} in three ways that matter when pricing a renewal. That method ignores the + * active flag entirely; it treats an open-ended chart (no end date) as valid only up to today's business date, so a + * deposit renewing on a future maturity date finds no chart at all; and it returns the first match from an unordered + * set, so two charts covering the same date resolve arbitrarily. + *

+ * Returns null when the product offers no active chart in force on that date. + */ + public InterestRateChart activeChart(final LocalDate target) { + if (this.charts == null || target == null) { + return null; + } + InterestRateChart activeChart = null; + for (final InterestRateChart chart : this.charts) { + if (!chart.isActive()) { + continue; // retired rate version + } + final LocalDate fromDate = chart.getFromDate(); + final LocalDate endDate = chart.getEndDate(); + if (fromDate == null || fromDate.isAfter(target)) { + continue; // not yet in force + } + if (endDate != null && endDate.isBefore(target)) { + continue; // already superseded + } + if (activeChart == null || fromDate.isAfter(activeChart.getFromDate())) { + activeChart = chart; + } + } + return activeChart; + } + public DepositProductTermAndPreClosure depositProductTermAndPreClosure() { return this.productTermAndPreClosure; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java index 85b0bc8bb..f64d3e481 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccount.java @@ -828,6 +828,28 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom return savingsTransaction; } + /** + * The last non-reversed transaction dated strictly BEFORE {@code date}, or null when none exists. + *

+ * Distinct from {@link #findLastTransaction(LocalDate)}, which matches the date exactly. Used to seed the opening + * balance of an interest calculation that starts at a pivot date: a transaction dated ON that date falls inside the + * first posting period and is applied there, so seeding from it would count it twice, while requiring an exact match + * would leave the opening balance at zero on any pivot date that happens to carry no transaction. + *

+ * Relies on {@code transactions} being ordered ascending by date, as its {@code @OrderBy} declares, so the final + * match is the latest one. + */ + protected SavingsAccountTransaction findLastTransactionBefore(final LocalDate date) { + SavingsAccountTransaction candidate = null; + for (final SavingsAccountTransaction transaction : getTransactions()) { + if (transaction.isNotReversed() && !transaction.isReversalTransaction() + && DateUtils.isBefore(transaction.getTransactionDate(), date)) { + candidate = transaction; + } + } + return candidate; + } + protected SavingsAccountTransaction findLastFilteredTransactionWithPivotConfig(final LocalDate date) { SavingsAccountTransaction savingsTransaction = null; List trans = getSavingsAccountTransactionsWithPivotConfig(); @@ -921,13 +943,23 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom if (backdatedTxnsAllowedTill) { transaction = findLastFilteredTransactionWithPivotConfig(startInterestCalculationDate); } else { - transaction = findLastTransaction(startInterestCalculationDate); + // Strictly before the pivot, not on it: anything dated on the pivot date belongs to the + // first posting period and is applied there, so seeding from it would apply it twice -- + // and an exact-date match would leave this at zero whenever the pivot date carries no + // transaction of its own, silently accruing the whole term on a zero balance. + transaction = findLastTransactionBefore(startInterestCalculationDate); } if (transaction == null) { periodStartingBalance = Money.zero(this.currency); - } else { + } else if (backdatedTxnsAllowedTill) { periodStartingBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } else { + // summary.runningBalanceOnPivotDate is @Transient and only ever populated by the + // assembler on the backdated-transactions path, so reading it here returned zero and + // silently wiped the opening balance. The balance carried by the last transaction + // before the start date is the same figure, and is always present. + periodStartingBalance = transaction.getRunningBalance(this.currency); } } else { periodStartingBalance = Money.zero(this.currency); @@ -3140,7 +3172,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } } - private LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { + protected LocalDate calculateDateAccountIsLockedUntil(final LocalDate activationLocalDate) { LocalDate lockedInUntilLocalDate = null; final PeriodFrequencyType lockinPeriodFrequencyType = PeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType); 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 90bb30861..57a2828df 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 @@ -61,6 +61,8 @@ public interface DepositAccountWritePlatformService { CommandProcessingResult prematureCloseRDAccount(Long savingsId, JsonCommand command); + CommandProcessingResult processFdPayment(Long savingsId, JsonCommand command); + SavingsAccountTransaction initiateSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType); SavingsAccountTransaction withdrawSavingsTransfer(Long accountId, LocalDate transferDate, DepositAccountType depositAccountType);