diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java index def3e27f7..bafe4a1c7 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsAccountData.java @@ -217,6 +217,21 @@ public final class SavingsAccountData implements Serializable { private BigDecimal interestPosting; private BigDecimal overdraftPosting; + /** + * True when this deposit pays its interest out to a linked savings account every period (FD Special). + * Such interest never stays in the account, so it must never compound -- every period earns on the + * principal alone. Transient because it is only read by the batch interest-posting engine. + */ + private transient boolean transferInterestToLinkedAccount; + + public void setTransferInterestToLinkedAccount(final boolean transferInterestToLinkedAccount) { + this.transferInterestToLinkedAccount = transferInterestToLinkedAccount; + } + + public boolean isTransferInterestToLinkedAccount() { + return this.transferInterestToLinkedAccount; + } + public static SavingsAccountData importInstanceIndividual(Long clientId, Long productId, Long fieldOfficerId, LocalDate submittedOnDate, BigDecimal nominalAnnualInterestRate, EnumOptionData interestCompoundingPeriodTypeEnum, EnumOptionData interestPostingPeriodTypeEnum, EnumOptionData interestCalculationTypeEnum, 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 47b02dcf0..d1bbdc50c 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 @@ -150,6 +150,18 @@ public class FixedDepositAccount extends SavingsAccount { } } + /** + * Re-forces the standing instructions that make FD Special work -- see + * {@link DepositAccountTermAndPreClosure#forceMonthlyPayoutToLinkedSavings}. Called from the modify-application + * write path after this same request's own linked-account resolution has settled, since + * {@link #modifyApplication} (via {@code accountTermAndPreClosure.update}) has no fdType awareness and would + * otherwise let a SPECIAL account's transferInterestToLinkedAccount/onAccountClosureType/transferToSavingsId + * be silently unset by that same request. + */ + public void forceSpecialFdMonthlyPayoutSettings(final Long transferToSavingsId) { + this.accountTermAndPreClosure.forceMonthlyPayoutToLinkedSavings(transferToSavingsId); + } + @Override protected BigDecimal getEffectiveInterestRateAsFraction(final MathContext mc, final LocalDate interestPostingUpToDate) { boolean isPreMatureClosure = false; @@ -250,7 +262,13 @@ public class FixedDepositAccount extends SavingsAccount { totalInterestPayable = totalInterestPayable.plus(postingPeriod.getInterestEarned()); } final Money depositAmount = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount()); - final Money maturityAmount = depositAmount.plus(totalInterestPayable); + // FD Special pays each period's interest out to the linked savings account every month instead of + // compounding it into this account's own balance (see DepositAccountAssembler and + // TransferInterestToSavingsTasklet) -- so unlike a cumulative FD, this account's own balance never + // actually grows to depositAmount + totalInterestPayable by maturity; it stays at depositAmount + // throughout the term. Showing the compounded projection here would be a customer-facing figure + // that never happens. SINGLE/DOUBLE FDs are unaffected -- they still get the real compounded value. + final Money maturityAmount = "SPECIAL".equalsIgnoreCase(fdType()) ? depositAmount : depositAmount.plus(totalInterestPayable); this.accountTermAndPreClosure.updateMaturityDetails(maturityAmount.getAmount(), maturityDate); } @@ -358,6 +376,91 @@ public class FixedDepositAccount extends SavingsAccount { return allPostingPeriods; } + /** + * Read-only period-by-period (e.g. month-wise, when posting is Monthly) projected interest schedule across + * the account's current term -- built with the exact same {@link PostingPeriod} engine as + * {@link #calculateInterestPayable}, but deliberately NOT sharing that method's body: that one also calls + * {@code this.summary.updateFromInterestPeriodSummaries(...)}, which overwrites the account's real + * totalInterestEarned/lastInterestCalculationDate -- fine when it runs once as part of an actual save, but + * corrupting if repeated on every GET request. This variant skips that call entirely, so it is safe to + * invoke as many times as a read-service likes without ever mutating the account. Used to show FD Special + * customers/staff exactly how much interest is projected to post each month across the whole term, since + * that figure otherwise only exists internally as a discarded intermediate value. + */ + public List projectInterestSchedule(final MathContext mc, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, + final Integer financialYearBeginningMonth) { + List transactions = retreiveOrderedNonInterestPostingTransactions(); + if (transactions.isEmpty()) { + // Account activated but not yet funded (funding is a separate deposit step) -- project the + // schedule as if the planned deposit amount lands on day one, the same synthetic-transaction + // approach updateMaturityDateAndAmountBeforeAccountActivation uses for the pre-activation + // maturity preview. Without this every period here would show zero interest against a real, + // still-empty transaction history, while the maturity amount (forced to depositAmount for + // SPECIAL FDs) already assumes the account gets funded -- this keeps the two consistent. + final Money transactionAmountMoney = Money.of(getCurrency(), this.accountTermAndPreClosure.depositAmount()); + final SavingsAccountTransaction syntheticDeposit = SavingsAccountTransaction.deposit(null, office(), null, + this.accountSubmittedOrActivationDate(), transactionAmountMoney, null); + syntheticDeposit.setRunningBalance(transactionAmountMoney); + syntheticDeposit.updateCumulativeBalanceAndDates(this.getCurrency(), interestCalculatedUpto()); + transactions = new ArrayList<>(); + transactions.add(syntheticDeposit); + } + final LocalDate maturityDate = maturityDate() != null ? maturityDate() : calculateMaturityDate(); + // calculateInterestPayable's own "maturityDate" parameter is always fed maturityDate.minusDays(1) + // by its one real caller (see updateMaturityDateAndAmount above) -- the raw maturity date is only + // used for the end-of-day-balance reset either side of the calculation. Missing this -1 here made + // determineInterestPostingPeriods run one day too far, pushing the final period's end date (and + // therefore its amount) past the account's actual maturity date. + final LocalDate interestCalculationUpto = maturityDate.minusDays(1); + final boolean isPreMatureClosure = false; + + this.resetAccountTransactionsEndOfDayBalances(transactions, maturityDate); + try { + final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType + .fromInt(this.interestPostingPeriodType); + final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType + .fromInt(this.interestCompoundingPeriodType); + final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType + .fromInt(this.interestCalculationDaysInYearType); + final List postedAsOnTransactionDates = getManualPostingDates(); + + final List postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods( + depositStartDate(), interestCalculationUpto, postingPeriodType, financialYearBeginningMonth, + postedAsOnTransactionDates); + + final List allPostingPeriods = new ArrayList<>(); + Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount()) + : Money.zero(currency); + + final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType + .fromInt(this.interestCalculationType); + final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, interestCalculationUpto, isPreMatureClosure); + final Collection interestPostTransactions = this.savingsHelper.fetchPostInterestTransactionIds(getId()); + final boolean isInterestTransfer = false; + final Money minBalanceForInterestCalculation = Money.of(getCurrency(), minBalanceForInterestCalculation()); + final List savingsAccountTransactionDetailsForPostingPeriodList = toSavingsAccountTransactionDetailsForPostingPeriodList( + transactions); + + for (final LocalDateInterval periodInterval : postingPeriodIntervals) { + final boolean isUserPosting = postedAsOnTransactionDates.contains(periodInterval.endDate()); + final PostingPeriod postingPeriod = PostingPeriod.createFrom(periodInterval, periodStartingBalance, + savingsAccountTransactionDetailsForPostingPeriodList, this.currency, compoundingPeriodType, + interestCalculationType, interestRateAsFraction, daysInYearType.getValue(), interestCalculationUpto, + interestPostTransactions, isInterestTransfer, minBalanceForInterestCalculation, + isSavingsInterestPostingAtCurrentPeriodEnd, isUserPosting, financialYearBeginningMonth); + + periodStartingBalance = postingPeriod.closingBalance(); + allPostingPeriods.add(postingPeriod); + } + + this.savingsHelper.calculateInterestForAllPostingPeriods(this.currency, allPostingPeriods, getLockedInUntilDate(), + isTransferInterestToOtherAccount()); + return allPostingPeriods; + } finally { + this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate()); + } + } + public void prematureClosure(final AppUser currentUser, final JsonCommand command, final Map actualChanges) { final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) @@ -573,7 +676,13 @@ public class FixedDepositAccount extends SavingsAccount { interestPostingTransactionDate, interestEarnedToBePostedForPeriod, interestPostingPeriod.isUserPosting()); this.transactions.add(newPostingTransaction); recalucateDailyBalanceDetails = true; - } else { + } else if (!interestAlreadyPaidOut(postingTransaction)) { + // Once a period's interest has actually been swept out to the linked savings account it is + // settled history: reversing and re-posting it here would rewrite interest the customer was + // already paid and leave this account's books disagreeing with the cash that left it. But a + // posting still sitting IN the account is fair game -- that is how a stale amount (e.g. one + // the batch engine compounded) gets corrected at maturity. Testing the individual period, + // not the account-wide transfer flag, is the distinction that matters here. final boolean correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod); if (correctionRequired) { postingTransaction.reverse(); @@ -595,6 +704,57 @@ public class FixedDepositAccount extends SavingsAccount { this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); } + /** + * Whether this period's posted interest has already left the account for the linked savings account. + *

+ * The sweep withdrawal carries the same transaction date as the interest posting it pays out (see + * DepositAccountReadPlatformService#retrieveDataForInterestTransfer, which pairs them by date), and + * {@link #interestTransferWithdrawalTransactionIds()} returns those WITHDRAWAL ids -- not the posting + * ids -- so the match has to be on date, not on id. + */ + private boolean interestAlreadyPaidOut(final SavingsAccountTransaction postingTransaction) { + final Collection sweepWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (sweepWithdrawalIds.isEmpty()) { + return false; + } + for (final SavingsAccountTransaction transaction : this.transactions) { + if (transaction.isNotReversed() && sweepWithdrawalIds.contains(transaction.getId()) + && DateUtils.isEqual(transaction.getTransactionDate(), postingTransaction.getTransactionDate())) { + return true; + } + } + return false; + } + + /** + * Guards the base {@link SavingsAccount#postInterest} correction loop, which base + * {@code SavingsAccountDomainServiceJpa#handleWithdrawal} re-triggers after every withdrawal -- including the + * sweep withdrawal that transfers a period's interest out to the linked savings account. Without this guard, + * that recalculation reverses and reposts the just-paid-out period at a freshly-recomputed (and slightly + * different, e.g. rounding) amount, leaving the already-executed sweep withdrawal mismatched against the new + * posting. + */ + @Override + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return !interestAlreadyPaidOut(postingTransaction); + } + + /** + * The interest-transfer sweep withdrawal only ever fires for a period whose interest is already posted (see + * DepositAccountReadPlatformService#retrieveDataForInterestTransfer), so there is nothing new for this + * recalculation to find. Worse, at the moment this withdrawal's own post-withdrawal recalculation would run, + * the m_account_transfer_transaction linking row that marks it as a transfer withdrawal does not exist yet + * (it is created by AccountTransfersWritePlatformServiceImpl only after both transfer legs are booked) -- so + * the exclusion in {@link #resetAccountTransactionsEndOfDayBalances} cannot yet recognise it, and the + * recalculation would wrongly treat it as a real balance-reducing transaction for its own and every later + * period, reversing and reposting already-settled interest at a corrupted amount. Skipping the recalculation + * for this specific withdrawal avoids the corruption at its source. + */ + @Override + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return !isInterestTransfer; + } + public void postPreMaturityInterest(final LocalDate accountCloseDate, final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth) { @@ -809,6 +969,24 @@ public class FixedDepositAccount extends SavingsAccount { return uptoMaturityDate; } + /** + * An FD that pays its interest out monthly (FD Special) carries a transfer-out WITHDRAWAL for every posted + * period, and {@link #retreiveOrderedNonInterestPostingTransactions()} strips those out before the + * interest engine sees them. The balance-segment walk has to strip the same ones: that withdrawal is dated + * the same day as the posting it pays out, so leaving it in ends the funding deposit's balance segment one + * day early while the engine's own list has nothing to cover that day -- costing the first period one + * earning day (e.g. 6 days instead of 25-Jul..31-Jul's real 7). + *

+ * A no-op for every other account: the excluded-id lookup short-circuits to empty unless + * {@link #isTransferInterestToOtherAccount()} is true. + */ + @Override + protected void resetAccountTransactionsEndOfDayBalances(final List accountTransactionsSorted, + final LocalDate interestPostingUpToDate) { + super.resetAccountTransactionsEndOfDayBalances(excludeInterestTransferWithdrawals(accountTransactionsSorted), + interestPostingUpToDate); + } + public void validateDomainRules() { final List dataValidationErrors = new ArrayList<>(); final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java index 80b9c1a0d..45c852a78 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountDomainServiceJpa.java @@ -114,13 +114,17 @@ public class SavingsAccountDomainServiceJpa implements SavingsAccountDomainServi final LocalDate today = DateUtils.getBusinessLocalDate(); - if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) { - account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd, - financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals); + if (account.isPostWithdrawalInterestRecalculationRequired(transactionBooleanValues.isInterestTransfer())) { + if (account.isBeforeLastPostingPeriod(transactionDate, backdatedTxnsAllowedTill)) { + account.postInterest(mc, today, transactionBooleanValues.isInterestTransfer(), isSavingsInterestPostingAtCurrentPeriodEnd, + financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, postReversals); + } else { + account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(), + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, + backdatedTxnsAllowedTill, postReversals); + } } else { - account.calculateInterestUsing(mc, today, transactionBooleanValues.isInterestTransfer(), - isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth, postInterestOnDate, backdatedTxnsAllowedTill, - postReversals); + account.refreshBalancesAndSummaryAfterTransfer(today, backdatedTxnsAllowedTill, postReversals); } List depositAccountOnHoldTransactions = null; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java index f315eb3c2..4b255ddf0 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountInterestPostingServiceImpl.java @@ -119,7 +119,7 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } else { correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); } - if (correctionRequired) { + if (correctionRequired && !isInterestAlreadySweptOut(interestPostingTransactionDate, savingsAccountData)) { boolean applyWithHoldTaxForOldTransaction = false; postingTransaction.reverse(); @@ -185,6 +185,28 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI return savingsAccountData; } + /** + * Mirrors {@code FixedDepositAccount#interestAlreadyPaidOut}: for an FD Special account whose interest sweeps + * out to a linked savings account, a withdrawal dated the same day as an interest posting means that period's + * interest has already left the account (see DepositAccountReadPlatformService#retrieveDataForInterestTransfer, + * which pairs them by date). Recalculating and reposting it here -- as this batch job otherwise would for + * every account, FD or not -- would silently disagree with money that has already moved. Scoped to FD Special + * only, so plain savings and recurring deposit batch posting is unaffected. + */ + private boolean isInterestAlreadySweptOut(final LocalDate interestPostingTransactionDate, final SavingsAccountData savingsAccountData) { + if (!savingsAccountData.isTransferInterestToLinkedAccount() + || !DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId())) { + return false; + } + for (final SavingsAccountTransactionData transaction : savingsAccountData.getSavingsAccountTransactionData()) { + if (transaction.isWithdrawal() && transaction.isNotReversed() + && DateUtils.isEqual(transaction.getTransactionDate(), interestPostingTransactionDate)) { + return true; + } + } + return false; + } + protected SavingsAccountTransactionData findTransactionFor(final LocalDate postingDate, final List transactions) { SavingsAccountTransactionData transaction = null; @@ -341,8 +363,19 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI } } + // A deposit that sweeps its interest out to a linked savings account every period (FD Special) + // must never compound: the interest does not stay in the account, so each period earns on the + // principal alone. This used to be hard-coded false here, which made this batch engine disagree + // with the domain engine (SavingsAccount#calculateInterestUsing, which passes the real flag) -- + // the same account could be posted a compounded amount by the nightly job and a simple amount by + // its own maturity run, minutes apart. Deliberately scoped to fixed deposits so recurring + // deposits keep their existing behaviour. + // getDepositTypeId() rather than depositAccountType(), which dereferences the enum unguarded -- + // this runs per account inside a batch job, so a null deposit type must not take the whole run down. + final boolean interestTransferEnabled = savingsAccountData.isTransferInterestToLinkedAccount() + && DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId()); this.savingsHelper.calculateInterestForAllPostingPeriods(monetaryCurrency, allPostingPeriods, - getLockedInUntilLocalDate(savingsAccountData), false); + getLockedInUntilLocalDate(savingsAccountData), interestTransferEnabled); savingsAccountData.getSummary().updateFromInterestPeriodSummaries(monetaryCurrency, allPostingPeriods); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java index ce7ce86fc..6ade6bfff 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountReadPlatformServiceImpl.java @@ -349,6 +349,7 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead final StringBuilder sqlBuilder = new StringBuilder(400); sqlBuilder.append("sa.id as id, sa.account_no as accountNo, sa.external_id as externalId, "); sqlBuilder.append("sa.deposit_type_enum as depositType, "); + sqlBuilder.append("datp.transfer_interest_to_linked_account as transferInterestToLinkedAccount, "); sqlBuilder.append("c.id as clientId, c.office_id as clientOfficeId, "); sqlBuilder.append("g.id as groupId, g.office_id as groupOfficeId, "); sqlBuilder.append("sa.status_enum as statusEnum, "); @@ -446,6 +447,12 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead sqlBuilder.append("left join acc_product_mapping apm2 on apm2.product_id = sp.id and apm2.financial_account_type=18 "); sqlBuilder.append("left join acc_product_mapping apm3 on apm3.product_id = sp.id and apm3.financial_account_type = 11 "); sqlBuilder.append("left join acc_product_mapping apm4 on apm4.product_id = sp.id and apm4.financial_account_type = 17 "); + // Needed so this batch engine can tell a monthly-payout deposit (FD Special) from a + // cumulative one: interest that is swept out to a linked account each month must never + // compound, and without this flag the engine compounded it. Left join because ordinary + // savings accounts have no term-and-preclosure row at all. + sqlBuilder.append( + "left join m_deposit_account_term_and_preclosure datp on datp.savings_account_id = sa.id "); this.schemaSql = sqlBuilder.toString(); } @@ -668,6 +675,9 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead savingsAccountData.setGlAccountIdForInterestOnSavings(glAccountIdForInterestOnSavings); savingsAccountData.setGlAccountIdForSavingsControl(glAccountIdForSavingsControl); + // Monthly-payout deposits must not compound: their interest leaves the account every + // period, so each period earns on the principal alone. + savingsAccountData.setTransferInterestToLinkedAccount(rs.getBoolean("transferInterestToLinkedAccount")); } if (!transMap.containsValue(transactionId)) { @@ -1142,8 +1152,8 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead java.util.Map nomineeMap = gson.fromJson(nomineeDataStr, new com.google.gson.reflect.TypeToken>() {}.getType()); accountData.setNomineeData(nomineeMap); - } catch (Exception ignored) { - // intentionally ignored + } catch (Exception ignored) { + // intentionally ignored } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java index aad5ddcf4..10573774a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsAccountWritePlatformServiceJpaRepositoryImpl.java @@ -643,7 +643,7 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForInterestReceivable()); } } else { - accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestPayable()); + accountTransaction.setAccountDebit(savingsAccountData.getGlAccountIdForInterestOnSavings()); accountTransaction.setAccountCredit(savingsAccountData.getGlAccountIdForSavingsControl()); } } 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 e2007c80e..1b2d39ca3 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 @@ -151,6 +151,9 @@ public class DepositAccountInterestRateChart extends AbstractPersistableCustom return identifiers; } + /** + * Extension point for the interest-posting correction loop in {@link #postInterest}: lets a subclass veto + * reversing/reposting an already-existing posting transaction for a period. Defaults to always allowing the + * correction, so plain savings and recurring deposit behaviour is unchanged. + */ + protected boolean isInterestPostingCorrectionAllowed(final SavingsAccountTransaction postingTransaction) { + return true; + } + + /** + * Extension point checked by {@code SavingsAccountDomainServiceJpa#handleWithdrawal} before it re-triggers an + * interest recalculation right after creating a withdrawal. Defaults to always requiring the full recalculation + * (via {@link #postInterest} / {@link #calculateInterestUsing}), so plain savings and recurring deposit + * behaviour is unchanged. When this returns false, {@link #refreshBalancesAndSummaryAfterTransfer} is called + * instead. + */ + public boolean isPostWithdrawalInterestRecalculationRequired(final boolean isInterestTransfer) { + return true; + } + + /** + * Lightweight alternative to {@link #postInterest} used right after a withdrawal when the full interest + * recalculation is skipped (see {@link #isPostWithdrawalInterestRecalculationRequired}). Refreshes running + * balances and the account summary -- the bookkeeping {@link #postInterest} always performs at its tail, + * regardless of whether any posting correction was needed -- without re-running the interest + * posting/correction loop that recalculation drives. + */ + public void refreshBalancesAndSummaryAfterTransfer(final LocalDate interestPostingUpToDate, final boolean backdatedTxnsAllowedTill, + final boolean postReversals) { + Money openingAccountBalance = Money.zero(this.currency); + if (backdatedTxnsAllowedTill) { + if (this.summary.getLastInterestCalculationDate() == null) { + openingAccountBalance = Money.zero(this.currency); + } else { + openingAccountBalance = Money.of(this.currency, this.summary.getRunningBalanceOnPivotDate()); + } + } + recalculateDailyBalances(openingAccountBalance, interestPostingUpToDate, backdatedTxnsAllowedTill, postReversals); + if (!backdatedTxnsAllowedTill) { + this.summary.updateSummary(this.currency, this.savingsAccountTransactionSummaryWrapper, this.transactions); + } else { + this.summary.updateSummaryWithPivotConfig(this.currency, this.savingsAccountTransactionSummaryWrapper, null, + this.savingsAccountTransactions); + } + } + public void postInterest(final MathContext mc, final LocalDate interestPostingUpToDate, final boolean isInterestTransfer, final boolean isSavingsInterestPostingAtCurrentPeriodEnd, final Integer financialYearBeginningMonth, final LocalDate postInterestOnDate, final boolean backdatedTxnsAllowedTill, final boolean postReversals) { @@ -581,7 +627,7 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom } else { correctionRequired = postingTransaction.hasNotAmount(interestEarnedToBePostedForPeriod.negated()); } - if (correctionRequired) { + if (correctionRequired && isInterestPostingCorrectionAllowed(postingTransaction)) { boolean applyWithHoldTaxForOldTransaction = false; postingTransaction.reverse(); SavingsAccountTransaction reversal = null; @@ -1047,14 +1093,59 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom .sorted(new SavingsAccountTransactionComparator()).collect(Collectors.toList()); } + /** + * Transactions that should NOT count as part of this account's own balance history for interest + * calculation purposes -- excludes interest-posting credits (they're the thing being calculated, not an + * input to it) AND, for accounts that transfer their interest out to a linked account + * (isTransferInterestToOtherAccount(), e.g. FD Special), the paired WITHDRAWAL transaction that later + * sweeps that same interest back out (see TransferInterestToSavingsTasklet). + *

+ * Without also excluding that withdrawal, only the credit half of the round-trip gets stripped out here + * -- the debit half stays, permanently understating this account's balance (and therefore every + * subsequent period's interest) by that amount from the first transfer onward, even though the real + * ledger balance never actually changed. fetchPostInterestTransactionIds(getId()) returns exactly the + * withdrawal-transaction ids that are the FROM side of a real INTEREST_TRANSFER account transfer on this + * account, so excluding those ids here closes that gap. + */ + protected Collection interestTransferWithdrawalTransactionIds() { + if (getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount()) { + return Collections.emptyList(); + } + return this.savingsHelper.fetchPostInterestTransactionIds(getId()); + } + + /** + * The given transactions minus the interest-transfer withdrawal legs, exactly as + * {@link #retreiveOrderedNonInterestPostingTransactions()} removes them before the interest engine ever + * sees them. + *

+ * Those two sets MUST agree. {@link #resetAccountTransactionsEndOfDayBalances} derives each transaction's + * balance segment by walking backwards and ending the previous segment one day before the next + * transaction -- so a transaction present there but stripped out here truncates the preceding segment by a + * day, with nothing left in the engine's own list to account for that day. On an FD Special, whose + * transfer-out withdrawal always carries the same date as the interest posting it pays out, that silently + * cost the very first period one earning day. + */ + protected List excludeInterestTransferWithdrawals( + final List transactions) { + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); + if (interestTransferWithdrawalIds.isEmpty()) { + return transactions; + } + return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId())) + .collect(Collectors.toList()); + } + protected List retreiveOrderedNonInterestPostingTransactions() { final List listOfTransactionsSorted = retrieveListOfTransactions(); final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } } @@ -1066,10 +1157,12 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom final List listOfTransactionsSorted = retrieveSortedTransactions(); final List orderedNonInterestPostingTransactions = new ArrayList<>(); + final Collection interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(); for (final SavingsAccountTransaction transaction : listOfTransactionsSorted) { if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed()) - && transaction.isNotReversed() && !transaction.isReversalTransaction()) { + && transaction.isNotReversed() && !transaction.isReversalTransaction() + && !interestTransferWithdrawalIds.contains(transaction.getId())) { orderedNonInterestPostingTransactions.add(transaction); } }