From 2569e0f836ada28695003ab9816020e61a15e886 Mon Sep 17 00:00:00 2001 From: anshul Date: Fri, 9 Oct 2026 18:45:41 +0530 Subject: [PATCH] feat(investment): interest schedule API for Interest Entry 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 --- .../api/InvestmentPurchaseApiResource.java | 19 ++ .../data/InvestmentInterestScheduleData.java | 75 ++++++++ .../service/InvestmentInterestCalculator.java | 169 +++++++++++++++++ ...estmentMasterWritePlatformServiceImpl.java | 4 +- ...InvestmentPurchaseReadPlatformService.java | 49 ++++- ...tmentPurchaseWritePlatformServiceImpl.java | 29 ++- .../InvestmentInterestCalculatorTest.java | 173 ++++++++++++++++++ 7 files changed, 497 insertions(+), 21 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculator.java create mode 100644 fineract-provider/src/test/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculatorTest.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java index b1ccfdb17..5aebaf4f6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java @@ -28,14 +28,19 @@ import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.springframework.stereotype.Component; @@ -67,6 +72,20 @@ public class InvestmentPurchaseApiResource { return this.readPlatformService.retrieveOne(purchaseId); } + @GET + @Path("{purchaseId}/interest-schedule") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Interest schedule of a holding", description = "Half-yearly coupons and 31 Mar / 30 Sep closing provisions " + + "from purchase to maturity. Calculated only — nothing is posted. asOnDate (dd-MM-yyyy) defaults to the business date.") + public InvestmentInterestScheduleData retrieveInterestSchedule( + @PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId, + @QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY); + final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null + : LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy")); + return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn); + } + @POST @Consumes({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java new file mode 100644 index 000000000..4e5dda5de --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java @@ -0,0 +1,75 @@ +/** + * 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.data; + +import java.math.BigDecimal; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +/** + * Interest schedule of one holding: every half-yearly coupon and every 31 March / 30 September closing provision from + * purchase to maturity. Calculated, not yet posted. + */ +@Getter +@Builder +public class InvestmentInterestScheduleData { + + private final Long purchaseId; + private final String dealNo; + private final String certNo; + private final String scriptCode; + private final String scriptDesc; + private final BigDecimal faceValue; + private final BigDecimal unitValue; + private final BigDecimal balanceUnits; + private final BigDecimal purchaseRate; + private final BigDecimal bookValue; + private final BigDecimal intRate; + private final String dealDate; + private final String lastIntDate; + private final String maturityDate; + /** Interest paid to the seller at purchase — already in Interest Receivable. */ + private final BigDecimal purchasedAccruedInterest; + private final BigDecimal couponPerHalfYear; + private final String asOnDate; + private final String interestGlCode; + private final String interestGlName; + private final List rows; + + @Getter + @Builder + public static class Row { + + /** COUPON (cash received) or PROVISION (closing accrual). */ + private final String type; + private final String intDate; + private final String fromDate; + private final String toDate; + private final int days; + /** Coupon received, or interest earned since the last coupon up to the closing. */ + private final BigDecimal grossInterest; + /** Interest Receivable balance before this line (purchased accrued interest / earlier provision). */ + private final BigDecimal alreadyBooked; + /** Provision to book now, or the part of the coupon that is new income. */ + private final BigDecimal amount; + /** True when the date is on or before the as-on date. */ + private final boolean due; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculator.java new file mode 100644 index 000000000..ef708d59d --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculator.java @@ -0,0 +1,169 @@ +/** + * 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 java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.Month; +import java.util.ArrayList; +import java.util.List; +import java.util.TreeSet; + +/** + * Interest arithmetic for half-yearly coupon securities, as the bank's existing CBS does it (Jitendra Sir): + *
    + *
  • days are counted 30/360 — every month is 30 days, a half-year is 180, a year 360;
  • + *
  • the coupon is fixed: face value × rate ÷ 2, paid on the maturity day/month and six months either side;
  • + *
  • at each closing (31 March, 30 September) the interest earned since the last coupon is booked as a provision (Dr + * Interest Receivable / Cr Interest Income), even though the cash arrives at the next coupon;
  • + *
  • when the coupon arrives, the provision already booked is cleared and only the rest is new income.
  • + *
+ */ +public final class InvestmentInterestCalculator { + + private static final BigDecimal THIRTY_SIX_THOUSAND = BigDecimal.valueOf(36000); + private static final BigDecimal TWO_HUNDRED = BigDecimal.valueOf(200); + + public static final String TYPE_PROVISION = "PROVISION"; + public static final String TYPE_COUPON = "COUPON"; + + private InvestmentInterestCalculator() {} + + /** One line of the interest schedule. */ + public record ScheduleRow(String type, LocalDate date, LocalDate fromDate, LocalDate toDate, int days, BigDecimal grossInterest, + BigDecimal alreadyBooked, BigDecimal amount) { + } + + /** + * Days between two dates on a 30/360 basis. Counting runs from the day after {@code from} up to and including + * {@code to}: 10-08 → 30-09 is 50 days, 10-08 → 08-10 is 58 days, 30-06 → 30-09 is 90 days. + */ + public static int days360(final LocalDate from, final LocalDate to) { + if (from == null || to == null || !to.isAfter(from)) { + return 0; + } + final int d1 = Math.min(from.getDayOfMonth(), 30); + final int d2 = Math.min(to.getDayOfMonth(), 30); + return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1); + } + + /** Interest on {@code faceValue} at {@code ratePercent} p.a. for {@code days} (30/360), rounded to the rupee. */ + public static BigDecimal interestForDays(final BigDecimal faceValue, final BigDecimal ratePercent, final int days) { + return faceValue.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(THIRTY_SIX_THOUSAND, 0, RoundingMode.HALF_UP); + } + + /** The fixed half-yearly coupon: face value × rate ÷ 2. */ + public static BigDecimal halfYearCoupon(final BigDecimal faceValue, final BigDecimal ratePercent) { + return faceValue.multiply(ratePercent).divide(TWO_HUNDRED, 0, RoundingMode.HALF_UP); + } + + /** k-th coupon date counting back from maturity — measured from maturity each time so month-end days survive. */ + private static LocalDate couponBack(final LocalDate maturity, final int k) { + return maturity.minusMonths(6L * k); + } + + /** The most recent coupon date on or before {@code on}. */ + public static LocalDate lastCouponOnOrBefore(final LocalDate maturity, final LocalDate on) { + int k = 0; + LocalDate d = maturity; + while (d.isAfter(on)) { + k++; + d = couponBack(maturity, k); + } + return d; + } + + /** Coupon dates strictly after {@code from}, up to and including maturity, in date order. */ + public static List couponDatesAfter(final LocalDate maturity, final LocalDate from) { + final List dates = new ArrayList<>(); + int k = 0; + LocalDate d = maturity; + while (d.isAfter(from)) { + dates.add(0, d); + k++; + d = couponBack(maturity, k); + } + return dates; + } + + /** Closing dates (31 March, 30 September) from {@code from} up to {@code to}, inclusive. */ + public static List closingDates(final LocalDate from, final LocalDate to) { + final List dates = new ArrayList<>(); + for (int year = from.getYear(); year <= to.getYear(); year++) { + for (final LocalDate closing : List.of(LocalDate.of(year, Month.MARCH, 31), LocalDate.of(year, Month.SEPTEMBER, 30))) { + if (!closing.isBefore(from) && !closing.isAfter(to)) { + dates.add(closing); + } + } + } + return dates; + } + + /** + * The full interest schedule of a holding, from purchase to maturity. + * + * @param faceValue + * face value currently held + * @param ratePercent + * coupon rate % p.a. + * @param maturity + * maturity date (coupons fall on its day/month and six months either side) + * @param lastIntDate + * coupon date immediately before the purchase + * @param dealDate + * purchase deal date — closings before it belong to the seller + * @param purchasedAccrued + * interest paid to the seller at purchase; it already sits in Interest Receivable + */ + public static List schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity, + final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) { + final List rows = new ArrayList<>(); + if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null + || dealDate == null || !maturity.isAfter(lastIntDate)) { + return rows; + } + final List coupons = couponDatesAfter(maturity, lastIntDate); + final TreeSet events = new TreeSet<>(coupons); + for (final LocalDate closing : closingDates(dealDate, maturity)) { + if (closing.isAfter(lastIntDate) && !events.contains(closing)) { + events.add(closing); + } + } + + final BigDecimal coupon = halfYearCoupon(faceValue, ratePercent); + BigDecimal receivable = purchasedAccrued == null ? BigDecimal.ZERO : purchasedAccrued; + LocalDate previousCoupon = lastIntDate; + for (final LocalDate date : events) { + final int days = days360(previousCoupon, date); + if (coupons.contains(date)) { + rows.add(new ScheduleRow(TYPE_COUPON, date, previousCoupon.plusDays(1), date, days, coupon, receivable, + coupon.subtract(receivable))); + receivable = BigDecimal.ZERO; + previousCoupon = date; + } else { + final BigDecimal earned = interestForDays(faceValue, ratePercent, days); + rows.add(new ScheduleRow(TYPE_PROVISION, date, previousCoupon.plusDays(1), date, days, earned, receivable, + earned.subtract(receivable))); + receivable = earned; + } + } + return rows; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java index a8cb4d024..4f61e991c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java @@ -157,7 +157,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste return this.partyRepository.saveAndFlush(party); } catch (final DataIntegrityViolationException e) { throw new PlatformDataIntegrityException("error.msg.investmentparty.duplicate", "A party with this code already exists", - "partyCode", party.getPartyCode()); + "partyCode", party.getPartyCode(), e); } } @@ -247,7 +247,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste return this.scriptRepository.saveAndFlush(script); } catch (final DataIntegrityViolationException e) { throw new PlatformDataIntegrityException("error.msg.investmentscript.duplicate", "A script with this code already exists", - "scriptCode", script.getScriptCode()); + "scriptCode", script.getScriptCode(), e); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java index 33c38e2cf..71f00343c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java @@ -18,9 +18,15 @@ */ package org.apache.fineract.portfolio.investment.service; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.util.List; import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException; import org.springframework.data.domain.Sort; @@ -32,6 +38,11 @@ import org.springframework.transaction.annotation.Transactional; @Transactional(readOnly = true) public class InvestmentPurchaseReadPlatformService { + private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + /** Income head for coupon and provision interest (GL 400401 under 400400 INCOME FROM INVESTMENTS). */ + private static final String INTEREST_GL_CODE = "400401"; + private static final String INTEREST_GL_NAME = "INTEREST ON GOVT SECURITIES"; + private final InvestmentPurchaseRepository purchaseRepository; /** All deals, newest first — the Holdings register. */ @@ -40,7 +51,41 @@ public class InvestmentPurchaseReadPlatformService { } public InvestmentPurchaseData retrieveOne(final Long purchaseId) { - return this.purchaseRepository.findById(purchaseId).map(InvestmentPurchaseData::from) - .orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + return InvestmentPurchaseData.from(find(purchaseId)); + } + + /** + * Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still + * held. Lines on or before {@code asOnDate} (default: business date) are marked due. + */ + public InvestmentInterestScheduleData retrieveInterestSchedule(final Long purchaseId, final LocalDate asOnDate) { + final InvestmentPurchase p = find(purchaseId); + final LocalDate asOn = asOnDate == null ? DateUtils.getBusinessLocalDate() : asOnDate; + final BigDecimal unitValue = p.getScript().getFaceValuePerUnit(); + final BigDecimal faceHeld = p.getBalanceUnits().multiply(unitValue); + final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); + + final List rows = InvestmentInterestCalculator + .schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate(), p.getAccruedInterest()).stream() + .map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date())) + .fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest()) + .alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build()) + .toList(); + + return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()) + .scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld) + .unitValue(unitValue).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()).bookValue(p.getBookValue()) + .intRate(p.getIntRate()).dealDate(format(p.getDealDate())).lastIntDate(format(p.getLastIntDate())) + .maturityDate(format(p.getMaturityDate())).purchasedAccruedInterest(p.getAccruedInterest()) + .couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO) + .asOnDate(format(asOn)).interestGlCode(INTEREST_GL_CODE).interestGlName(INTEREST_GL_NAME).rows(rows).build(); + } + + private InvestmentPurchase find(final Long purchaseId) { + return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + } + + private static String format(final LocalDate date) { + return date == null ? null : date.format(DATE_FORMAT); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index 973db29dc..350f9ae4c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -98,7 +98,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur throw rule("script.inactive", "Script " + script.getScriptCode() + " is inactive"); } if (!script.getBondType().equals(bondType)) { - throw rule("script.bondtype.mismatch", "Script " + script.getScriptCode() + " is a " + script.getBondType() + ", not " + bondType); + throw rule("script.bondtype.mismatch", + "Script " + script.getScriptCode() + " is a " + script.getBondType() + ", not " + bondType); } if (!party.isActive()) { throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive"); @@ -167,11 +168,10 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur } final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP); final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP); - final int days = days360(lastIntDate, p.getDealDate()); + final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate()); final BigDecimal intRate = zeroIfNull(script.getIntRate()); // Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir. - final BigDecimal accrued = faceValue.multiply(intRate).multiply(BigDecimal.valueOf(days)) - .divide(BigDecimal.valueOf(36000), 0, RoundingMode.HALF_UP); + final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days); p.setFaceValue(faceValue); p.setPurchaseRate(rate); @@ -204,18 +204,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur p.setSettlementAmount(p.getBookValue()); } - /** Days between two dates on a 30/360 basis (every month 30 days), as the bank's existing CBS counts them. */ - static int days360(final LocalDate from, final LocalDate to) { - if (from == null || to == null || !to.isAfter(from)) { - return 0; - } - final int d1 = Math.min(from.getDayOfMonth(), 30); - final int d2 = Math.min(to.getDayOfMonth(), 30); - return (to.getYear() - from.getYear()) * 360 + (to.getMonthValue() - from.getMonthValue()) * 30 + (d2 - d1); - } - /** Approximate yield: coupon adjusted for the discount/premium spread over the security's life, over the price. */ - private static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) { + static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) { if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) { return coupon; } @@ -226,14 +216,19 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur /** Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. */ private String nextDealNo(final LocalDate dealDate) { - final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1; - final String prefix = fyStart + String.format("%02d", (fyStart + 1) % 100); + final String prefix = dealNoPrefix(dealDate); final Integer max = this.jdbcTemplate.queryForObject( "SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class, prefix + "%"); return prefix + String.format("%06d", (max == null ? 0 : max) + 1); } + /** Financial year (April–March) of the deal date: 2026-04-01 … 2027-03-31 → "202627". */ + static String dealNoPrefix(final LocalDate dealDate) { + final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1; + return fyStart + String.format("%02d", (fyStart + 1) % 100); + } + private String nextCertNo() { final Integer max = this.jdbcTemplate.queryForObject( "SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class); diff --git a/fineract-provider/src/test/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculatorTest.java b/fineract-provider/src/test/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculatorTest.java new file mode 100644 index 000000000..398da9445 --- /dev/null +++ b/fineract-provider/src/test/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestCalculatorTest.java @@ -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 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 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 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 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()); + } +}