utafrali commented on code in PR #6782:
URL: https://github.com/apache/fineract/pull/6782#discussion_r4410795141


##########
fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java:
##########
@@ -291,32 +291,72 @@ public BigDecimal calculateInterest(final 
CompoundInterestValues compoundInteres
         // for each compounding period accumulate the amount of interest
         // to be applied to the balanced for interest calculation
         for (final CompoundingPeriod compoundingPeriod : 
this.compoundingPeriods) {
+            final BigDecimal interestUnrounded = interestOn(compoundingPeriod, 
compoundInterestValues.getcompoundedInterest());
+            compound(compoundingPeriod, interestUnrounded, 
compoundInterestValues);
+            interestEarned = interestEarned.add(interestUnrounded);
+        }
 
-            final BigDecimal interestUnrounded = 
compoundingPeriod.calculateInterest(this.interestCompoundingType,
-                    this.interestCalculationType, 
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
-                    this.daysInYear, 
this.minBalanceForInterestCalculation.getAmount(), 
this.overdraftInterestRateAsFraction,
-                    this.minOverdraftForInterestCalculation.getAmount());
-            BigDecimal unCompoundedInterest = 
compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
-            
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
-            LocalDate compoundingPeriodEndDate = 
compoundingPeriod.getPeriodInterval().endDate();
-            if 
(!SavingsCompoundingInterestPeriodType.DAILY.equals(this.interestCompoundingType))
 {
-                compoundingPeriodEndDate = 
determineInterestPeriodEndDateFrom(compoundingPeriod.getPeriodInterval().startDate(),
-                        this.interestCompoundingType, 
compoundingPeriod.getPeriodInterval().endDate(),
-                        this.getFinancialYearBeginningMonth());
-            }
+        updateInterestEarned(interestEarned);
+        return interestEarned;
+    }
 
-            if 
(compoundingPeriodEndDate.equals(compoundingPeriod.getPeriodInterval().endDate()))
 {
-                BigDecimal interestCompounded = 
compoundInterestValues.getcompoundedInterest().add(unCompoundedInterest);
-                
compoundInterestValues.setCompoundedInterest(interestCompounded);
-                compoundInterestValues.setZeroForInterestToBeUncompounded();
-            }
-            interestEarned = interestEarned.add(interestUnrounded);
+    /**
+     * Calculates the interest of the two periods that the overdraft split 
builds for one posting interval, one for the
+     * days in credit and one for the overdrawn days. They cover different 
days of the same compounding periods, so they
+     * are calculated one compounding period at a time and the interest of 
both is compounded at its end, as it is when
+     * the interval is not split. Calculated one after the other, the second 
period would get the interest of the first
+     * compounded into all its days, before the end of the compounding period 
it was earned in.
+     */
+    public static BigDecimal calculateInterest(final PostingPeriod first, 
final PostingPeriod second,
+            final CompoundInterestValues compoundInterestValues) {

Review Comment:
   Having a `public static BigDecimal calculateInterest(PostingPeriod, 
PostingPeriod, CompoundInterestValues)` alongside an instance method `public 
BigDecimal calculateInterest(CompoundInterestValues)` with the same name is 
legal Java but will surprise readers: calling `period.calculateInterest(...)` 
when you meant the static overload is an easy mistake. A name like 
`calculateInterestForSplitInterval` would make the call sites in 
`CompoundInterestHelper` self-documenting and remove any ambiguity.



##########
fineract-savings/src/test/java/org/apache/fineract/portfolio/savings/service/SavingsAccountOverdraftInterestSplitTest.java:
##########
@@ -118,14 +121,58 @@ void 
overdraftSplitMonthlyPostingCountsOverdraftCarriedIntoNextMonthOnce() {
                 .isCloseTo(interestFor(withoutSplit, businessDate, 
februaryStart, februaryEnd), within(new BigDecimal("0.01")));
     }
 
+    @Test
+    void 
overdraftSplitMonthlyCompoundingChargesOverdraftInterestForDaysOverdrawnInTheSameMonth()
 {
+        final LocalDate businessDate = LocalDate.of(2025, 2, 2);
+        final LocalDate januaryEnd = LocalDate.of(2025, 1, 31);
+
+        // in credit from Jan 1 to Jan 7, then overdrawn by 10 until the month 
end: the interest of the days in
+        // credit is only compounded at the end of January, so it must not 
count for the overdrawn days
+        final SavingsAccountData withoutSplit = 
accountOverdrawnWithinFirstMonth(false, 
SavingsPostingInterestPeriodType.MONTHLY, "21");
+        final SavingsAccountData withSplit = 
accountOverdrawnWithinFirstMonth(true, 
SavingsPostingInterestPeriodType.MONTHLY, "21");
+
+        // 10,000 for 7 days at 10% and -10 for 24 days at 21%, both over 365 
days
+        assertThat(interestFor(withSplit, businessDate, ACTIVATION_DATE, 
januaryEnd, period -> !period.isOverdraftInterest()))
+                .as("interest for the days in 
credit").isEqualByComparingTo("19.18");
+        assertThat(interestFor(withSplit, businessDate, ACTIVATION_DATE, 
januaryEnd, PostingPeriod::isOverdraftInterest))
+                .as("overdraft interest for the overdrawn 
days").isEqualByComparingTo("-0.14");
+        assertThat(interestFor(withSplit, businessDate, ACTIVATION_DATE, 
januaryEnd)).as("January interest with the overdraft split")
+                .isCloseTo(interestFor(withoutSplit, businessDate, 
ACTIVATION_DATE, januaryEnd), within(new BigDecimal("0.01")));
+    }
+
+    @Test
+    void 
overdraftSplitMonthlyCompoundingPaysNoInterestForOverdrawnDaysWithoutOverdraftRate()
 {
+        final SavingsAccountData account = 
accountOverdrawnWithinFirstMonth(true, 
SavingsPostingInterestPeriodType.MONTHLY, "0");
+
+        assertThat(interestFor(account, LocalDate.of(2025, 2, 2), 
ACTIVATION_DATE, LocalDate.of(2025, 1, 31),
+                PostingPeriod::isOverdraftInterest)).as("interest for the 
overdrawn days").isEqualByComparingTo(BigDecimal.ZERO);
+    }
+
+    @Test
+    void overdraftSplitQuarterlyPostingCompoundsBothPartsAtEachMonthEnd() {
+        final LocalDate businessDate = LocalDate.of(2025, 4, 2);
+        final LocalDate quarterEnd = LocalDate.of(2025, 3, 31);
+
+        final SavingsAccountData withoutSplit = 
accountOverdrawnWithinFirstMonth(false, 
SavingsPostingInterestPeriodType.QUATERLY, "21");
+        final SavingsAccountData withSplit = 
accountOverdrawnWithinFirstMonth(true, 
SavingsPostingInterestPeriodType.QUATERLY, "21");
+
+        assertThat(interestFor(withSplit, businessDate, ACTIVATION_DATE, 
quarterEnd)).as("Q1 interest with the overdraft split")
+                .isCloseTo(interestFor(withoutSplit, businessDate, 
ACTIVATION_DATE, quarterEnd), within(new BigDecimal("0.01")));
+    }
+
     private BigDecimal interestFor(final SavingsAccountData account, final 
LocalDate businessDate, final LocalDate periodStart,
             final LocalDate periodEnd) {
+        return interestFor(account, businessDate, periodStart, periodEnd, 
period -> true);
+    }
+
+    private BigDecimal interestFor(final SavingsAccountData account, final 
LocalDate businessDate, final LocalDate periodStart,
+            final LocalDate periodEnd, final Predicate<PostingPeriod> part) {
         final List<PostingPeriod> postingPeriods = 
service.calculateInterestUsing(MATH_CONTEXT, businessDate, false, false, 1, 
null, false,
                 account);
         return postingPeriods.stream()
                 .filter(period -> 
period.getPeriodInterval().startDate().equals(periodStart)
                         && 
period.getPeriodInterval().endDate().equals(periodEnd))
-                
.map(PostingPeriod::getInterestEarned).reduce(Money.zero(CURRENCY), 
Money::plus).getAmount();
+                
.filter(part).map(PostingPeriod::getInterestEarned).reduce(Money.zero(CURRENCY),
 Money::plus).getAmount();
     }
 
     private SavingsAccountData accountWithTwoDeposits(final boolean 
allowOverdraft, final boolean interestReceivableMapped) {

Review Comment:
   The PR description explicitly calls out daily compounding as a special case 
— the first period's daily interest feeds into the second period's balance each 
day — and the static method has a dedicated branch for it 
(`SavingsCompoundingInterestPeriodType.DAILY.equals(first.interestCompoundingType)`).
 There is no test exercising that path. For a financial calculation fix this is 
a meaningful gap: please add a test analogous to 
`overdraftSplitQuarterlyPostingCompoundsBothPartsAtEachMonthEnd` but with 
`SavingsCompoundingInterestPeriodType.DAILY`, asserting that the split result 
is within tolerance of the non-split result.



##########
fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java:
##########
@@ -48,23 +48,42 @@ public Money calculateInterestForAllPostingPeriods(final 
MonetaryCurrency curren
         BigDecimal compoundedInterest = BigDecimal.ZERO;
         BigDecimal unCompoundedInterest = BigDecimal.ZERO;
         final CompoundInterestValues compoundInterestValues = new 
CompoundInterestValues(compoundedInterest, unCompoundedInterest);
-        for (final PostingPeriod postingPeriod : allPeriods) {
-
-            final BigDecimal interestEarnedThisPeriod = 
postingPeriod.calculateInterest(compoundInterestValues);
+        int index = 0;
+        while (index < allPeriods.size()) {
+            final PostingPeriod postingPeriod = allPeriods.get(index);
+            // the overdraft split builds two periods for one posting 
interval, which are calculated together
+            final PostingPeriod otherPeriodOfInterval = index + 1 < 
allPeriods.size()
+                    && hasSameInterval(postingPeriod, allPeriods.get(index + 
1)) ? allPeriods.get(index + 1) : null;
+            final List<PostingPeriod> periodsOfInterval;
+            if (otherPeriodOfInterval == null) {
+                postingPeriod.calculateInterest(compoundInterestValues);
+                periodsOfInterval = List.of(postingPeriod);
+            } else {
+                PostingPeriod.calculateInterest(postingPeriod, 
otherPeriodOfInterval, compoundInterestValues);
+                periodsOfInterval = List.of(postingPeriod, 
otherPeriodOfInterval);
+            }
 
-            final Money moneyToBePostedForPeriod = Money.of(currency, 
interestEarnedThisPeriod);
+            for (final PostingPeriod period : periodsOfInterval) {
+                final Money moneyToBePostedForPeriod = Money.of(currency, 
period.getInterestEarnedUnrounded());
 
-            interestEarned = interestEarned.plus(moneyToBePostedForPeriod);
-            // these checks are for fixed deposit account for not include
-            // interest for accounts which has post interest to linked savings
-            // account and if already transfered then it includes in interest
-            // calculation.
-            if (!(postingPeriod.isInterestTransfered() || 
!interestTransferEnabled
-                    || (lockUntil != null && 
!DateUtils.isAfter(postingPeriod.dateOfPostingTransaction(), lockUntil)))) {
-                compoundInterestValues.setCompoundedInterest(BigDecimal.ZERO);
+                interestEarned = interestEarned.plus(moneyToBePostedForPeriod);
+                // these checks are for fixed deposit account for not include
+                // interest for accounts which has post interest to linked 
savings
+                // account and if already transfered then it includes in 
interest
+                // calculation.
+                if (!(period.isInterestTransfered() || !interestTransferEnabled
+                        || (lockUntil != null && 
!DateUtils.isAfter(period.dateOfPostingTransaction(), lockUntil)))) {
+                    
compoundInterestValues.setCompoundedInterest(BigDecimal.ZERO);
+                }
             }
+            index += periodsOfInterval.size();
         }
 
         return interestEarned;
     }
+
+    private static boolean hasSameInterval(final PostingPeriod postingPeriod, 
final PostingPeriod other) {
+        return 
postingPeriod.getPeriodInterval().startDate().equals(other.getPeriodInterval().startDate())
+                && 
postingPeriod.getPeriodInterval().endDate().equals(other.getPeriodInterval().endDate());
+    }

Review Comment:
   `hasSameInterval` detects the paired split periods by checking that 
consecutive entries share the same start and end date. This relies on the two 
halves always being placed adjacently in `allPeriods`. Nothing in this file 
validates or documents that ordering guarantee. If the list ever comes in a 
different order (e.g., sorted differently elsewhere), the pairing silently 
fails and the original bug reappears without any error. A short comment stating 
the expected invariant — or an assertion checked before the loop — would make 
this assumption explicit.



##########
fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/PostingPeriod.java:
##########
@@ -291,32 +291,72 @@ public BigDecimal calculateInterest(final 
CompoundInterestValues compoundInteres
         // for each compounding period accumulate the amount of interest
         // to be applied to the balanced for interest calculation
         for (final CompoundingPeriod compoundingPeriod : 
this.compoundingPeriods) {
+            final BigDecimal interestUnrounded = interestOn(compoundingPeriod, 
compoundInterestValues.getcompoundedInterest());
+            compound(compoundingPeriod, interestUnrounded, 
compoundInterestValues);
+            interestEarned = interestEarned.add(interestUnrounded);
+        }
 
-            final BigDecimal interestUnrounded = 
compoundingPeriod.calculateInterest(this.interestCompoundingType,
-                    this.interestCalculationType, 
compoundInterestValues.getcompoundedInterest(), this.interestRateAsFraction,
-                    this.daysInYear, 
this.minBalanceForInterestCalculation.getAmount(), 
this.overdraftInterestRateAsFraction,
-                    this.minOverdraftForInterestCalculation.getAmount());
-            BigDecimal unCompoundedInterest = 
compoundInterestValues.getuncompoundedInterest().add(interestUnrounded);
-            
compoundInterestValues.setuncompoundedInterest(unCompoundedInterest);
-            LocalDate compoundingPeriodEndDate = 
compoundingPeriod.getPeriodInterval().endDate();
-            if 
(!SavingsCompoundingInterestPeriodType.DAILY.equals(this.interestCompoundingType))
 {
-                compoundingPeriodEndDate = 
determineInterestPeriodEndDateFrom(compoundingPeriod.getPeriodInterval().startDate(),
-                        this.interestCompoundingType, 
compoundingPeriod.getPeriodInterval().endDate(),
-                        this.getFinancialYearBeginningMonth());
-            }
+        updateInterestEarned(interestEarned);
+        return interestEarned;
+    }
 
-            if 
(compoundingPeriodEndDate.equals(compoundingPeriod.getPeriodInterval().endDate()))
 {
-                BigDecimal interestCompounded = 
compoundInterestValues.getcompoundedInterest().add(unCompoundedInterest);
-                
compoundInterestValues.setCompoundedInterest(interestCompounded);
-                compoundInterestValues.setZeroForInterestToBeUncompounded();
-            }
-            interestEarned = interestEarned.add(interestUnrounded);
+    /**
+     * Calculates the interest of the two periods that the overdraft split 
builds for one posting interval, one for the
+     * days in credit and one for the overdrawn days. They cover different 
days of the same compounding periods, so they
+     * are calculated one compounding period at a time and the interest of 
both is compounded at its end, as it is when
+     * the interval is not split. Calculated one after the other, the second 
period would get the interest of the first
+     * compounded into all its days, before the end of the compounding period 
it was earned in.
+     */
+    public static BigDecimal calculateInterest(final PostingPeriod first, 
final PostingPeriod second,
+            final CompoundInterestValues compoundInterestValues) {
+        BigDecimal firstInterestEarned = BigDecimal.ZERO;
+        BigDecimal secondInterestEarned = BigDecimal.ZERO;
+        for (int i = 0; i < first.compoundingPeriods.size(); i++) {
+            final CompoundingPeriod compoundingPeriod = 
first.compoundingPeriods.get(i);

Review Comment:
   The loop assumes `second.compoundingPeriods.size() == 
first.compoundingPeriods.size()`, which holds today because both split periods 
share the same `periodInterval` and `interestCompoundingType` and compounding 
periods are derived from those alone. However, this invariant is not expressed 
in code — a future change to how split periods are constructed would produce an 
`IndexOutOfBoundsException` with no obvious cause.
   
   Add a guard at the top of the method:
   ```java
   if (first.compoundingPeriods.size() != second.compoundingPeriods.size()) {
       throw new IllegalArgumentException(
           "Overdraft split periods must have the same number of compounding 
periods, got " +
           first.compoundingPeriods.size() + " and " + 
second.compoundingPeriods.size());
   }
   ```



##########
fineract-core/src/main/java/org/apache/fineract/portfolio/savings/domain/interest/CompoundInterestHelper.java:
##########
@@ -48,23 +48,42 @@ public Money calculateInterestForAllPostingPeriods(final 
MonetaryCurrency curren
         BigDecimal compoundedInterest = BigDecimal.ZERO;
         BigDecimal unCompoundedInterest = BigDecimal.ZERO;
         final CompoundInterestValues compoundInterestValues = new 
CompoundInterestValues(compoundedInterest, unCompoundedInterest);
-        for (final PostingPeriod postingPeriod : allPeriods) {
-
-            final BigDecimal interestEarnedThisPeriod = 
postingPeriod.calculateInterest(compoundInterestValues);
+        int index = 0;
+        while (index < allPeriods.size()) {
+            final PostingPeriod postingPeriod = allPeriods.get(index);
+            // the overdraft split builds two periods for one posting 
interval, which are calculated together
+            final PostingPeriod otherPeriodOfInterval = index + 1 < 
allPeriods.size()
+                    && hasSameInterval(postingPeriod, allPeriods.get(index + 
1)) ? allPeriods.get(index + 1) : null;
+            final List<PostingPeriod> periodsOfInterval;
+            if (otherPeriodOfInterval == null) {
+                postingPeriod.calculateInterest(compoundInterestValues);
+                periodsOfInterval = List.of(postingPeriod);
+            } else {
+                PostingPeriod.calculateInterest(postingPeriod, 
otherPeriodOfInterval, compoundInterestValues);
+                periodsOfInterval = List.of(postingPeriod, 
otherPeriodOfInterval);

Review Comment:
   The return value of `PostingPeriod.calculateInterest(postingPeriod, 
otherPeriodOfInterval, compoundInterestValues)` is silently discarded; the 
caller reads interest amounts via `getInterestEarnedUnrounded()` instead. The 
instance `calculateInterest` has the same issue at line 60. If the return value 
is no longer meaningful at the call site, changing both signatures to `void` 
removes the false impression that callers should use the returned `BigDecimal`. 
If it is kept as `BigDecimal` (for use in other contexts), annotate it 
`@SuppressWarnings("UnusedReturnValue")` or add a comment, otherwise static 
analysis tools will flag it as dead code.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to