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 3eb427dcf..039ca6d90 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 @@ -595,6 +595,62 @@ public class SavingsAccount extends AbstractAuditableWithUTCDateTimeCustom withholdTransactions.addAll(findWithHoldTransactions()); } + // Reverse manual interest postings that no longer line up with any period this run computed, + // BEFORE the periods below are applied. + // + // A user posting ("post interest as on D") lands its transaction on D-1, because + // SavingsHelper#determineInterestPostingPeriods closes the period at D-1 when it meets D in + // postedAsOnDates. But getManualPostingDates() then feeds that transaction's own date -- D-1 + // -- back in as a boundary on every later run, which closes a period at D-2 instead. So the + // posting's date matches no period end the second time around: findInterestPostingTransactionFor + // below misses it, it is never reversed or corrected, and it survives alongside a new posting + // covering the very same days. The member is credited twice, and total_interest_posted_derived + // drifts above what was actually earned. + // + // A posting is only treated as orphaned, and only reversed, when BOTH hold: + // - its date is not any period's posting date, so the loop below will never find and correct it; + // - some period that WILL be posted in this run (posting date on or before + // interestPostingUpToDate) covers that date, so the days it credited are being credited again. + // The second condition is what keeps this from ever destroying interest. A run whose remaining + // period only matures in the future posts nothing for those days -- the recalculation that + // follows a withdrawal is exactly that case -- and reversing there would silently take earned + // interest back off the member instead of de-duplicating it. + // Automatic postings are left alone: they sit on natural period ends, which always recur. + final Set periodPostingDates = new HashSet<>(); + final List repostedIntervals = new ArrayList<>(); + for (final PostingPeriod period : postingPeriods) { + periodPostingDates.add(period.dateOfPostingTransaction()); + if (!DateUtils.isAfter(period.dateOfPostingTransaction(), interestPostingUpToDate)) { + repostedIntervals.add(period.getPeriodInterval()); + } + } + final List postingsInScope = backdatedTxnsAllowedTill + ? getSavingsAccountTransactionsWithPivotConfig() + : getTransactions(); + for (final SavingsAccountTransaction transaction : new ArrayList<>(postingsInScope)) { + final LocalDate transactionDate = transaction.getTransactionDate(); + if (!transaction.isInterestPosting() || !transaction.isNotReversed() || transaction.isReversalTransaction() + || !transaction.isManualTransaction() || periodPostingDates.contains(transactionDate)) { + continue; + } + boolean coveredByAPostedPeriod = false; + for (final LocalDateInterval interval : repostedIntervals) { + if (interval.contains(transactionDate)) { + coveredByAPostedPeriod = true; + break; + } + } + if (!coveredByAPostedPeriod) { + continue; + } + transaction.reverse(); + final SavingsAccountTransaction strandedWithholdTransaction = findTransactionFor(transactionDate, withholdTransactions); + if (strandedWithholdTransaction != null) { + strandedWithholdTransaction.reverse(); + } + recalucateDailyBalanceDetails = true; + } + for (final PostingPeriod interestPostingPeriod : postingPeriods) { final LocalDate interestPostingTransactionDate = interestPostingPeriod.dateOfPostingTransaction(); final Money interestEarnedToBePostedForPeriod = interestPostingPeriod.getInterestEarned();