From 72221f704d0e7de109049e47150d17354f1dcdc6 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 26 Aug 2026 19:02:17 +0530 Subject: [PATCH] feat(deposits,clients): FD Special auto-configuration, interest schedule projection, and minor-age backend mirror Three related pieces of backend support for this session's FD Special (monthly interest payout) work, plus one more instance of the same transaction-ordering bug fixed elsewhere this session. 1. FD Special account creation/modification auto-configuration FD Special (fdType=SPECIAL) is a non-cumulative product: interest must post monthly and leave for a linked savings account, and maturity must auto-transfer the principal out -- regardless of whatever compounding/ posting period or maturity instruction the request otherwise carries. Previously none of this was enforced, so a SPECIAL account could be created with e.g. quarterly compounding, or a later "modify application" request could silently unset transferInterestToLinkedAccount / the maturity instruction / the linked account by mere omission of those fields from the payload, while the account kept displaying as "FD Special". - DepositAccountAssembler: on create, forces interestCompoundingPeriodType and interestPostingPeriodType to MONTHLY, transferInterestToSavings to true, transferToSavingsId to the (now required) linked savings account, and accountOnClosureType to TRANSFER_TO_SAVINGS, whenever fdType is SPECIAL. Throws a clear domain error if no linked account was selected. - DepositAccountTermAndPreClosure: new forceMonthlyPayoutToLinkedSavings(), re-applying those same three standing-instruction fields. - DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl: calls the equivalent re-enforcement on every modify-application request for a SPECIAL account, after the linked-account resolution in that same method has settled what account (if any) ends up linked. 2. FD Special projected interest schedule (engine + API) Staff/customers previously only saw a flat rate/12 approximation of monthly interest. Added a real, engine-computed month-by-month schedule: - DepositAccountWritePlatformService(Impl): new retrieveProjectedInterestSchedule(), running the same PostingPeriod engine the real maturity calculation uses (FixedDepositAccount# projectInterestSchedule), side-effect-free (@Transactional(readOnly)), clipping the last period's displayed end-date to maturityDate-1 (the engine otherwise rounds it out to the next calendar boundary even when maturity falls partway through), and flagging each period "paid" by matching real posted-interest transaction dates. - FixedDepositAccountsApiResource: computes and attaches the schedule, plus server-computed totalProjectedInterest/totalValueAtMaturity, to the FD template response for SPECIAL accounts only. - FixedDepositAccountData / DepositsApiConstants: carrier fields and response-parameter whitelist entries for the three new fields. 3. Minor-age threshold: backend now matches the frontend's shared constant Companion to this session's frontend minor-age-threshold change (18 -> 10, see client-age.util.ts). ClientKycApiResource's minor detection (which gates the required-KYC-document list, including whether PAN is mandatory) was still hardcoded to age < 18. Added ClientApiConstants.MINOR_AGE_THRESHOLD (10) and SENIOR_CITIZEN_AGE_ THRESHOLD (60) as the backend's own named constants, and switched the KYC endpoint to use MINOR_AGE_THRESHOLD, so the two sides can't silently disagree on who counts as a minor. 4. SavingsAccountTransactionComparator: same-date tie-break fixes another instance of this session's core reversal bug For a Special FD's interest-transfer sweep, the withdrawal shares the exact transaction_date of the interest posting it pays out. When that posting is reversed and recreated, the new posting transaction gets a higher id than the already-existing withdrawal; the comparator's id-order tiebreaker then sorted the withdrawal before the new posting, which understated the running balance shown between them (e.g. showing Rs.99,901.39 instead of Rs.100,000.00 immediately after the pair). Added an explicit tiebreaker so a same-date, same-createdDate interest posting always sorts before a withdrawal/debit. Co-Authored-By: Claude Sonnet 5 --- .../client/api/ClientApiConstants.java | 9 +++ .../savings/DepositsApiConstants.java | 4 ++ .../client/api/ClientKycApiResource.java | 9 ++- .../api/FixedDepositAccountsApiResource.java | 24 +++++++ .../domain/DepositAccountAssembler.java | 49 ++++++++++++-- ...WritePlatformServiceJpaRepositoryImpl.java | 67 +++++++++++++++++++ ...WritePlatformServiceJpaRepositoryImpl.java | 25 +++++++ .../savings/data/FixedDepositAccountData.java | 25 +++++++ .../DepositAccountTermAndPreClosure.java | 13 ++++ .../SavingsAccountTransactionComparator.java | 16 +++++ .../DepositAccountWritePlatformService.java | 4 ++ 11 files changed, 236 insertions(+), 9 deletions(-) diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java index 5f5e99096..daf7c1383 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/client/api/ClientApiConstants.java @@ -162,6 +162,15 @@ public class ClientApiConstants { public static final String ageDaysParamName = "ageDays"; public static final String nationalityIdParamName = "nationalityId"; public static final String isMinorParamName = "isMinor"; + /** + * Below this age a client is treated as a minor: guardian mapping required and a restricted KYC/OVD + * document set. Must stay in step with MINOR_AGE_THRESHOLD in the web app's client-age util -- the + * KYC template endpoint is authoritative for the document list, so a mismatch would serve an adult + * list (with mandatory PAN) for a client the UI classifies as a minor. + */ + public static final int MINOR_AGE_THRESHOLD = 10; + /** At or above this age a client qualifies for senior-citizen deposit rates. */ + public static final int SENIOR_CITIZEN_AGE_THRESHOLD = 60; public static final String guardianClientIdParamName = "guardianClientId"; public static final String guardianClientDisplayNameParamName = "guardianClientDisplayName"; public static final String guardianRelationParamName = "guardianRelation"; 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 f3e7eba4f..240914b13 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 @@ -589,6 +589,10 @@ public final class DepositsApiConstants { fixedDepositResponseData.add(SavingsApiConstants.jointHoldersParamName); fixedDepositResponseData.add(SavingsApiConstants.nominationFacilityParamName); fixedDepositResponseData.add(SavingsApiConstants.autoDebitFacilityParamName); + // FD Special's engine-computed monthly interest schedule (see FixedDepositAccountsApiResource#retrieveOne) + fixedDepositResponseData.add("projectedInterestSchedule"); + fixedDepositResponseData.add("totalProjectedInterest"); + fixedDepositResponseData.add("totalValueAtMaturity"); return fixedDepositResponseData; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java index ebc4e49a3..9391a8aac 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/client/api/ClientKycApiResource.java @@ -160,10 +160,13 @@ public class ClientKycApiResource { template.put("clientTypeName", clientTypeName); template.put("legalFormId", legalFormId); - // Minor detection mirrors the frontend's own rule (age < 18) so the document list - // served here stays consistent with what the UI computes from the same date of birth. + // Minor detection mirrors the frontend's own rule so the document list served here stays + // consistent with what the UI computes from the same date of birth. Both sides read the + // threshold from a single named constant -- when they disagree, this endpoint wins and would + // silently serve an adult document list (mandatory PAN) to a minor. final LocalDate dateOfBirth = client.dateOfBirth(); - final boolean isMinor = dateOfBirth != null && Period.between(dateOfBirth, LocalDate.now()).getYears() < 18; + final boolean isMinor = dateOfBirth != null + && Period.between(dateOfBirth, LocalDate.now()).getYears() < ClientApiConstants.MINOR_AGE_THRESHOLD; template.put("isMinor", isMinor); template.put("requiredDocuments", getRequiredDocuments(clientTypeName, legalFormId, isMinor)); 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 8d6e0c241..d0b4aa3f5 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 @@ -81,6 +81,7 @@ import org.apache.fineract.portfolio.savings.data.SavingsAccountChargeData; import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionData; import org.apache.fineract.portfolio.savings.service.DepositAccountPreMatureCalculationPlatformService; import org.apache.fineract.portfolio.savings.service.DepositAccountReadPlatformService; +import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService; import org.apache.fineract.portfolio.savings.service.FixedDepositAccountInterestCalculationService; import org.apache.fineract.portfolio.savings.service.SavingsAccountChargeReadPlatformService; import org.glassfish.jersey.media.multipart.FormDataContentDisposition; @@ -107,6 +108,7 @@ public class FixedDepositAccountsApiResource { private final BulkImportWorkbookPopulatorService bulkImportWorkbookPopulatorService; private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService; private final SqlValidator sqlValidator; + private final DepositAccountWritePlatformService depositAccountWritePlatformService; @GET @Path("template") @@ -245,6 +247,28 @@ public class FixedDepositAccountsApiResource { final FixedDepositAccountData accountTemplate = populateTemplateAndAssociations(accountId, account, staffInSelectedOfficeOnly, chargeStatus, uriInfo, mandatoryResponseParameters); accountTemplate.setActivationCharge(getActivationCharge(accountId)); + // FD Special customers/staff need to see exactly how much interest is projected each month across + // the whole term, not just a flat rate/12 approximation -- computed on demand from the real + // interest engine (FixedDepositAccount#projectInterestSchedule), side-effect-free, so this GET can + // safely recompute it on every request. SINGLE/DOUBLE FDs never get this field. + if ("SPECIAL".equalsIgnoreCase(account.getFdType())) { + final java.util.List> schedule = this.depositAccountWritePlatformService + .retrieveProjectedInterestSchedule(accountId, DepositAccountType.FIXED_DEPOSIT); + accountTemplate.setProjectedInterestSchedule(schedule); + // Computed once here -- the single source of truth for "what does the customer end up with + // overall" -- rather than the frontend re-summing the schedule array itself, the same way + // maturityAmount itself is always a backend-computed field, never a client-side derivation. + BigDecimal totalInterest = BigDecimal.ZERO; + for (final java.util.Map period : schedule) { + final Object amount = period.get("interestAmount"); + if (amount instanceof BigDecimal) { + totalInterest = totalInterest.add((BigDecimal) amount); + } + } + final BigDecimal principal = accountTemplate.getDepositAmount(); + final BigDecimal totalValue = principal != null ? principal.add(totalInterest) : totalInterest; + accountTemplate.setProjectedInterestTotals(totalInterest, totalValue); + } final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters(), mandatoryResponseParameters); return this.toApiJsonSerializer.serialize(settings, accountTemplate, 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 7fa657aa5..a0deb4eab 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 @@ -32,6 +32,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.expecte import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isCalendarInheritedParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isMandatoryDepositParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.isSeniorCitizenParamName; +import static org.apache.fineract.portfolio.savings.DepositsApiConstants.linkedAccountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.seniorCitizenInterestRateParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.mandatoryRecommendedDepositAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maturityInstructionIdParamName; @@ -68,6 +69,7 @@ import java.util.Locale; import java.util.Set; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.UnsupportedParameterException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; @@ -248,6 +250,14 @@ public class DepositAccountAssembler { } } + // FD Special (fdType=SPECIAL) is a non-cumulative, monthly-interest-payout product: interest must + // post every month and leave the account for its linked savings account (see + // assembleAccountTermAndPreClosure below and the maturity-instruction override there), regardless + // of whatever compounding/posting period the product or the request otherwise specifies. SINGLE and + // DOUBLE FD types are untouched by this flag. + final boolean isSpecialFixedDeposit = depositAccountType.isFixedDeposit() && product instanceof FixedDepositProduct + && "SPECIAL".equalsIgnoreCase(((FixedDepositProduct) product).getFdType()); + SavingsCompoundingInterestPeriodType interestCompoundingPeriodType = null; final Integer interestPeriodTypeValue = command.integerValueOfParameterNamed(interestCompoundingPeriodTypeParamName); if (interestPeriodTypeValue != null) { @@ -255,6 +265,13 @@ public class DepositAccountAssembler { } else { interestCompoundingPeriodType = product.interestCompoundingPeriodType(); } + if (isSpecialFixedDeposit) { + // Keep compounding no coarser than the forced Monthly posting below -- compounded interest gets + // reset to zero every period anyway once transferInterestToLinkedAccount is true (see + // CompoundInterestHelper), so this is only about keeping the interest engine's own + // posting/compounding-period expectations internally consistent, not a behaviour choice. + interestCompoundingPeriodType = SavingsCompoundingInterestPeriodType.MONTHLY; + } SavingsPostingInterestPeriodType interestPostingPeriodType = null; final Integer interestPostingPeriodTypeValue = command.integerValueOfParameterNamed(interestPostingPeriodTypeParamName); @@ -263,6 +280,9 @@ public class DepositAccountAssembler { } else { interestPostingPeriodType = product.interestPostingPeriodType(); } + if (isSpecialFixedDeposit) { + interestPostingPeriodType = SavingsPostingInterestPeriodType.MONTHLY; + } SavingsInterestCalculationType interestCalculationType = null; final Integer interestCalculationTypeValue = command.integerValueOfParameterNamed(interestCalculationTypeParamName); @@ -366,7 +386,7 @@ public class DepositAccountAssembler { if (depositAccountType.isFixedDeposit()) { final DepositProductTermAndPreClosure prodTermAndPreClosure = ((FixedDepositProduct) product).depositProductTermAndPreClosure(); final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command, - prodTermAndPreClosure); + prodTermAndPreClosure, isSpecialFixedDeposit); FixedDepositAccount fdAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product, fieldOfficer, accountNo, externalIdFactory.create(externalId), accountType, submittedOnDate, submittedBy, interestRate, @@ -380,7 +400,7 @@ public class DepositAccountAssembler { final DepositProductTermAndPreClosure prodTermAndPreClosure = ((RecurringDepositProduct) product) .depositProductTermAndPreClosure(); final DepositAccountTermAndPreClosure accountTermAndPreClosure = this.assembleAccountTermAndPreClosure(command, - prodTermAndPreClosure); + prodTermAndPreClosure, false); final DepositProductRecurringDetail prodRecurringDetail = ((RecurringDepositProduct) product).depositRecurringDetail(); final DepositAccountRecurringDetail accountRecurringDetail = this.assembleAccountRecurringDetail(command, @@ -420,7 +440,7 @@ public class DepositAccountAssembler { } public DepositAccountTermAndPreClosure assembleAccountTermAndPreClosure(final JsonCommand command, - final DepositProductTermAndPreClosure productTermAndPreclosure) { + final DepositProductTermAndPreClosure productTermAndPreclosure, final boolean forceMonthlyPayoutToLinkedSavings) { final DepositPreClosureDetail productPreClosure = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositPreClosureDetail(); final DepositTermDetail productTerm = (productTermAndPreclosure == null) ? null : productTermAndPreclosure.depositTermDetail(); @@ -435,17 +455,34 @@ public class DepositAccountAssembler { final SavingsPeriodFrequencyType depositPeriodFrequency = SavingsPeriodFrequencyType.fromInt(depositPeriodFrequencyId); final SavingsAccount account = null; final LocalDate expectedFirstDepositOnDate = command.localDateValueOfParameterNamed(expectedFirstDepositOnDateParamName); - final Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName); + Boolean trasferInterest = command.booleanPrimitiveValueOfParameterNamed(transferInterestToSavingsParamName); // calculate maturity amount final BigDecimal maturityAmount = null;// calculated and updated in // account final LocalDate maturityDate = null;// calculated and updated in account final Integer accountOnClosureTypeId = command.integerValueOfParameterNamed(maturityInstructionIdParamName); - final DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null + DepositAccountOnClosureType accountOnClosureType = accountOnClosureTypeId != null ? DepositAccountOnClosureType.fromInt(accountOnClosureTypeId) : null; - final Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName); + Long transferToSavingsId = command.longValueOfParameterNamed(transferToSavingsIdParamName); + + if (forceMonthlyPayoutToLinkedSavings) { + final Long linkedSavingsAccountId = command.longValueOfParameterNamed(linkedAccountParamName); + if (linkedSavingsAccountId == null) { + throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.special.requires.linked.savings.account", + "FD Special (Monthly Interest Payout) requires a linked savings account to be selected."); + } + // Non-cumulative payout FD: every month's interest leaves for the linked savings account + // (TransferInterestToSavingsTasklet) instead of compounding, and maturity re-uses the existing + // "Transfer to Savings" maturity-instruction path (see + // DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails) to move the + // principal out and close the account automatically, with no staff action needed. + trasferInterest = true; + transferToSavingsId = linkedSavingsAccountId; + accountOnClosureType = DepositAccountOnClosureType.TRANSFER_TO_SAVINGS; + } + return DepositAccountTermAndPreClosure.createNew(updatedProductPreClosure, updatedProductTerm, account, depositAmount, maturityAmount, maturityDate, depositPeriod, depositPeriodFrequency, expectedFirstDepositOnDate, accountOnClosureType, trasferInterest, transferToSavingsId); 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 dd60c181e..2ce19027e 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 @@ -107,6 +107,7 @@ import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrap import org.apache.fineract.portfolio.savings.domain.SavingsAccountStatusType; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransaction; import org.apache.fineract.portfolio.savings.domain.SavingsAccountTransactionRepository; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; import org.apache.fineract.portfolio.savings.exception.DepositAccountTransactionNotAllowedException; import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException; import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException; @@ -561,6 +562,72 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo .build(); } + /** + * FD Special (monthly interest payout) shows customers/staff exactly how much interest is projected to + * post each month across the account's whole term, rather than a flat rate/12 approximation -- computed + * with the real PostingPeriod engine (same one the actual maturity calculation uses) via + * FixedDepositAccount#projectInterestSchedule, which is deliberately side-effect-free. readOnly=true + * overrides this class's default @Transactional so nothing from that computation is ever flushed. + */ + @Transactional(readOnly = true) + @Override + public List> retrieveProjectedInterestSchedule(final Long savingsId, final DepositAccountType depositAccountType) { + final SavingsAccount account = this.depositAccountAssembler.assembleFrom(savingsId, depositAccountType); + if (!(account instanceof FixedDepositAccount)) { + return java.util.Collections.emptyList(); + } + + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + final MathContext mc = new MathContext(15, MoneyHelper.getRoundingMode()); + + final FixedDepositAccount fdAccount = (FixedDepositAccount) account; + final List postingPeriods = fdAccount.projectInterestSchedule(mc, isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth); + + // determineInterestPostingPeriods always rounds the LAST period out to the full natural boundary + // (e.g. calendar month-end for Monthly posting) even when the account's own maturity date falls + // partway through it -- the INTEREST AMOUNT for that period is still correctly computed only up to + // the real cutoff (interestCalculationUpto = maturityDate - 1, the same day postMaturityInterest + // clamps its own posting date to), but the period's displayed end-date is not, and would otherwise + // show a "To Date" past the account's actual maturity date. Clip it here so the schedule shown + // never claims interest for days beyond maturity, matching the money that is actually calculated. + final LocalDate lastCalculableDate = fdAccount.maturityDate() != null ? fdAccount.maturityDate().minusDays(1) : null; + + // A period is "paid" once a real (non-reversed) interest-posting transaction actually landed for it. + // Matched EXACTLY against that period's own end date (Fineract can date the posting transaction + // either on the period's own last day, or on the first day of the following period, depending on + // the isSavingsInterestPostingAtCurrentPeriodEnd configuration) -- not a date RANGE, which would + // wrongly also match the very next period (whose own start date is that same "day after"). + final List postedInterestDates = new ArrayList<>(); + for (final SavingsAccountTransaction transaction : fdAccount.getTransactions()) { + if (transaction.isInterestPostingAndNotReversed()) { + postedInterestDates.add(transaction.getTransactionDate()); + } + } + + final List> schedule = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + final LocalDate fromDate = period.getPeriodInterval().startDate(); + LocalDate toDate = period.getPeriodInterval().endDate(); + if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) { + toDate = lastCalculableDate; + } + final LocalDate toDateFinal = toDate; + final boolean paid = postedInterestDates.stream() + .anyMatch(postedDate -> postedDate.isEqual(toDateFinal) || postedDate.isEqual(toDateFinal.plusDays(1))); + + final Map row = new HashMap<>(); + row.put("fromDate", fromDate); + row.put("toDate", toDateFinal); + row.put("interestAmount", period.getInterestEarned().getAmount()); + row.put("paid", paid); + schedule.add(row); + } + return schedule; + } + @Transactional @Override public CommandProcessingResult postInterest(final Long savingsId, final DepositAccountType depositAccountType) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java index 7081fcdb7..7b0fcd06d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl.java @@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.group.exception.GroupNotFoundException; import org.apache.fineract.portfolio.note.domain.Note; import org.apache.fineract.portfolio.note.domain.NoteRepository; import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType; import org.apache.fineract.portfolio.savings.DepositsApiConstants; import org.apache.fineract.portfolio.savings.SavingsApiConstants; import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator; @@ -429,11 +430,18 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl final Long savingsAccountId = command.longValueOfParameterNamed(DepositsApiConstants.linkedAccountParamName); AccountAssociations accountAssociations = this.accountAssociationsRepository.findBySavingsIdAndType(accountId, AccountAssociationType.LINKED_ACCOUNT_ASSOCIATION.getValue()); + // Tracks whichever savings account ends up linked once this whole block finishes, across every + // branch below (kept as-is, removed, or replaced) -- used afterwards to re-enforce FD Special's + // "must always have a linked account" requirement regardless of what this request touched. + Long resolvedLinkedAccountId = accountAssociations != null && accountAssociations.linkedSavingsAccount() != null + ? accountAssociations.linkedSavingsAccount().getId() + : null; if (savingsAccountId == null) { if (accountAssociations != null) { if (this.fromJsonHelper.parameterExists(DepositsApiConstants.linkedAccountParamName, command.parsedJson())) { this.accountAssociationsRepository.delete(accountAssociations); changes.put(DepositsApiConstants.linkedAccountParamName, null); + resolvedLinkedAccountId = null; if (isLinkedAccRequired) { this.depositAccountDataValidator.throwLinkedAccountRequiredError(); } @@ -465,6 +473,23 @@ public class DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl impl changes.put(DepositsApiConstants.linkedAccountParamName, savingsAccountId); this.accountAssociationsRepository.save(accountAssociations); } + resolvedLinkedAccountId = savingsAccountId; + } + + // FD Special is a non-cumulative, monthly-interest-payout product: DepositAccountTermAndPreClosure#update + // above has no fdType awareness and will happily unset transferInterestToLinkedAccount, + // onAccountClosureType and transferToSavingsAccountId on this same request -- including by mere + // omission of maturityInstructionId/transferToSavingsId from the payload -- silently turning a SPECIAL + // FD into a plain cumulative one while it still displays as "FD Special". Re-force the three fields + // here, now that the linked-account resolution above has settled what account (if any) is linked. + if ("SPECIAL".equalsIgnoreCase(account.fdType())) { + if (resolvedLinkedAccountId == null) { + this.depositAccountDataValidator.throwLinkedAccountRequiredError(); + } + account.forceSpecialFdMonthlyPayoutSettings(resolvedLinkedAccountId); + changes.put(transferInterestToSavingsParamName, true); + changes.put(DepositsApiConstants.maturityInstructionIdParamName, DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue()); + this.savingAccountRepository.save(account); } // Replace nominees if provided 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 c9fd58529..edb6c2d02 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 @@ -182,6 +182,31 @@ public final class FixedDepositAccountData extends DepositAccountData { this.renewalHistory = renewalHistory; } + /** + * FD Special only: the real, engine-computed interest schedule across the account's whole term -- one entry + * per posting period (fromDate, toDate, interestAmount) -- so staff/customers can see exactly how much + * interest is projected to post each month instead of a flat rate/12 approximation. Null for every other + * FD type (see FixedDepositAccountsApiResource#retrieveOne). + */ + private java.util.List> projectedInterestSchedule; + private BigDecimal totalProjectedInterest; + private BigDecimal totalValueAtMaturity; + + public void setProjectedInterestSchedule(final java.util.List> projectedInterestSchedule) { + this.projectedInterestSchedule = projectedInterestSchedule; + } + + /** + * Server-computed sum of projectedInterestSchedule, and principal + that sum -- the same "what does the + * customer end up with overall" total the account view shows, computed once here instead of the + * frontend re-summing the schedule array itself, so there is a single source of truth (matching how + * maturityAmount itself already works). + */ + public void setProjectedInterestTotals(final BigDecimal totalProjectedInterest, final BigDecimal totalValueAtMaturity) { + this.totalProjectedInterest = totalProjectedInterest; + this.totalValueAtMaturity = totalValueAtMaturity; + } + // CBS account-level fields (stored per-account in m_savings_account) private String accountStatus; private String modeOfOperation; 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 9f15e9102..0490f3d09 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 @@ -340,6 +340,19 @@ public class DepositAccountTermAndPreClosure extends AbstractPersistableCustom> retrieveProjectedInterestSchedule(Long savingsId, DepositAccountType depositAccountType); + CommandProcessingResult postInterest(Long savingsId, DepositAccountType depositAccountType); CommandProcessingResult undoFDTransaction(Long savingsId, Long transactionId, boolean allowAccountTransferModification);