feat(investment): interest schedule API for Interest Entry
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

Add GET /v1/investment/purchases/{id}/interest-schedule. For a holding it
lists every half-yearly coupon and every 31 Mar / 30 Sep closing provision
from purchase to maturity, worked out on the face value still held:
provision = interest earned since the last coupon (30/360), and the coupon
clears what is already booked (purchased accrued interest or the earlier
provision). Lines up to the as-on date are marked due. Calculation only;
nothing is posted.

The day-count and interest arithmetic move into InvestmentInterestCalculator,
shared with Purchase Entry, and are covered by unit tests using Jitendra
Sir's worked example. Duplicate-code errors on the masters now keep the
original exception as the cause.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-09 18:45:41 +05:30
co-authored by Claude Opus 5.5
parent 81b5850b62
commit 2569e0f836
7 changed files with 497 additions and 21 deletions
@@ -0,0 +1,173 @@
/**
* 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.investment.service;
import static org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.TYPE_COUPON;
import static org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.TYPE_PROVISION;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import org.apache.fineract.portfolio.investment.service.InvestmentInterestCalculator.ScheduleRow;
import org.junit.jupiter.api.Test;
/**
* Figures from Jitendra Sir's worked example: 7.50% GOI 2034, face value 1 crore, coupons 10 Feb / 10 Aug, bought on
* 08-10-2026.
*/
class InvestmentInterestCalculatorTest {
private static final BigDecimal ONE_CRORE = new BigDecimal("10000000");
private static final BigDecimal RATE = new BigDecimal("7.50");
private static final LocalDate MATURITY = LocalDate.of(2034, 8, 10);
private static final LocalDate LAST_COUPON = LocalDate.of(2026, 8, 10);
private static final LocalDate DEAL = LocalDate.of(2026, 10, 8);
@Test
void days360CountsEveryMonthAsThirtyDays() {
assertEquals(50, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2026, 9, 30)));
assertEquals(58, InvestmentInterestCalculator.days360(LAST_COUPON, DEAL));
assertEquals(90, InvestmentInterestCalculator.days360(LocalDate.of(2026, 6, 30), LocalDate.of(2026, 9, 30)));
assertEquals(50, InvestmentInterestCalculator.days360(LocalDate.of(2027, 2, 10), LocalDate.of(2027, 3, 31)));
assertEquals(180, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2027, 2, 10)));
assertEquals(360, InvestmentInterestCalculator.days360(LAST_COUPON, LocalDate.of(2027, 8, 10)));
}
@Test
void days360IsZeroWhenDatesAreMissingOrReversed() {
assertEquals(0, InvestmentInterestCalculator.days360(null, DEAL));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, null));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, DEAL));
assertEquals(0, InvestmentInterestCalculator.days360(DEAL, LAST_COUPON));
}
@Test
void interestForDaysRoundsToTheRupee() {
assertEquals(new BigDecimal("120833"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 58));
assertEquals(new BigDecimal("104167"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 50));
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.interestForDays(ONE_CRORE, RATE, 180));
}
@Test
void halfYearCouponIsFaceValueTimesRateOverTwo() {
assertEquals(new BigDecimal("375000"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, RATE));
assertEquals(new BigDecimal("362500"), InvestmentInterestCalculator.halfYearCoupon(ONE_CRORE, new BigDecimal("7.25")));
}
@Test
void couponDatesRunSixMonthlyFromMaturity() {
assertEquals(LAST_COUPON, InvestmentInterestCalculator.lastCouponOnOrBefore(MATURITY, DEAL));
assertEquals(LAST_COUPON, InvestmentInterestCalculator.lastCouponOnOrBefore(MATURITY, LAST_COUPON));
final List<LocalDate> coupons = InvestmentInterestCalculator.couponDatesAfter(MATURITY, LAST_COUPON);
assertEquals(16, coupons.size());
assertEquals(LocalDate.of(2027, 2, 10), coupons.get(0));
assertEquals(LocalDate.of(2027, 8, 10), coupons.get(1));
assertEquals(MATURITY, coupons.get(15));
}
@Test
void monthEndMaturityKeepsMonthEndCoupons() {
final LocalDate maturity = LocalDate.of(2030, 8, 31);
final List<LocalDate> coupons = InvestmentInterestCalculator.couponDatesAfter(maturity, LocalDate.of(2029, 8, 31));
assertEquals(List.of(LocalDate.of(2030, 2, 28), maturity), coupons);
}
@Test
void closingDatesAreMarchAndSeptemberEnds() {
assertEquals(List.of(LocalDate.of(2027, 3, 31), LocalDate.of(2027, 9, 30)),
InvestmentInterestCalculator.closingDates(DEAL, LocalDate.of(2028, 3, 30)));
assertEquals(List.of(LocalDate.of(2026, 9, 30)),
InvestmentInterestCalculator.closingDates(LocalDate.of(2026, 9, 30), LocalDate.of(2026, 9, 30)));
}
@Test
void scheduleClearsPurchasedInterestAndProvisionsAtEachClosing() {
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, LAST_COUPON, DEAL,
new BigDecimal("120833"));
// 16 coupons + 15 closings (Mar 2027 … Mar 2034); 30-09-2026 is before the deal, so it is the seller's.
assertEquals(31, rows.size());
// First coupon: 1,20,833 paid to the seller is recovered, only the rest is our income.
assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000", "120833", "254167");
// 31 March closing: 50 days earned since 10 Feb, nothing booked yet.
assertRow(rows.get(1), TYPE_PROVISION, LocalDate.of(2027, 3, 31), LocalDate.of(2027, 2, 11), 50, "104167", "0", "104167");
// August coupon clears the March provision.
assertRow(rows.get(2), TYPE_COUPON, LocalDate.of(2027, 8, 10), LocalDate.of(2027, 2, 11), 180, "375000", "104167", "270833");
assertRow(rows.get(3), TYPE_PROVISION, LocalDate.of(2027, 9, 30), LocalDate.of(2027, 8, 11), 50, "104167", "0", "104167");
final ScheduleRow last = rows.get(rows.size() - 1);
assertEquals(TYPE_COUPON, last.type());
assertEquals(MATURITY, last.date());
// Over the life, income booked = all coupons − interest bought from the seller.
final BigDecimal totalIncome = rows.stream().map(ScheduleRow::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
assertEquals(new BigDecimal("375000").multiply(BigDecimal.valueOf(16)).subtract(new BigDecimal("120833")), totalIncome);
}
@Test
void closingOnACouponDateIsNotProvisionedTwice() {
// Coupons on 31 Mar / 30 Sep: every closing is a coupon day, so there are no provision lines.
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, LocalDate.of(2028, 3, 31),
LocalDate.of(2026, 9, 30), DEAL, BigDecimal.ZERO);
assertEquals(3, rows.size());
assertTrue(rows.stream().allMatch(r -> TYPE_COUPON.equals(r.type())));
}
@Test
void scheduleIsEmptyWithoutRateOrDates() {
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, BigDecimal.ZERO, MATURITY, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, null, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, null, DEAL, null).isEmpty());
}
@Test
void dealNumberFollowsTheAprilToMarchFinancialYear() {
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2026, 4, 1)));
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(DEAL));
assertEquals("202627", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2027, 3, 31)));
assertEquals("202728", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2027, 4, 1)));
assertEquals("209900", InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(LocalDate.of(2099, 12, 1)));
}
@Test
void approxYtmRisesWhenBoughtAtADiscount() {
final LocalDate issue = LocalDate.of(2024, 8, 10);
assertEquals(new BigDecimal("7.59"),
InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("99.50"), issue, MATURITY));
assertEquals(new BigDecimal("7.50"),
InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("100"), issue, MATURITY));
assertEquals(RATE, InvestmentPurchaseWritePlatformServiceImpl.approxYtm(RATE, new BigDecimal("99.50"), null, MATURITY));
}
private static void assertRow(final ScheduleRow row, final String type, final LocalDate date, final LocalDate from, final int days,
final String gross, final String alreadyBooked, final String amount) {
assertEquals(type, row.type());
assertEquals(date, row.date());
assertEquals(from, row.fromDate());
assertEquals(date, row.toDate());
assertEquals(days, row.days());
assertEquals(new BigDecimal(gross), row.grossInterest());
assertEquals(new BigDecimal(alreadyBooked), row.alreadyBooked());
assertEquals(new BigDecimal(amount), row.amount());
}
}