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
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:
+19
@@ -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 })
|
||||
|
||||
+75
@@ -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<Row> 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;
|
||||
}
|
||||
}
|
||||
+169
@@ -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):
|
||||
* <ul>
|
||||
* <li>days are counted 30/360 — every month is 30 days, a half-year is 180, a year 360;</li>
|
||||
* <li>the coupon is fixed: face value × rate ÷ 2, paid on the maturity day/month and six months either side;</li>
|
||||
* <li>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;</li>
|
||||
* <li>when the coupon arrives, the provision already booked is cleared and only the rest is new income.</li>
|
||||
* </ul>
|
||||
*/
|
||||
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<LocalDate> couponDatesAfter(final LocalDate maturity, final LocalDate from) {
|
||||
final List<LocalDate> 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<LocalDate> closingDates(final LocalDate from, final LocalDate to) {
|
||||
final List<LocalDate> 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<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
|
||||
final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) {
|
||||
final List<ScheduleRow> rows = new ArrayList<>();
|
||||
if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null
|
||||
|| dealDate == null || !maturity.isAfter(lastIntDate)) {
|
||||
return rows;
|
||||
}
|
||||
final List<LocalDate> coupons = couponDatesAfter(maturity, lastIntDate);
|
||||
final TreeSet<LocalDate> 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;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+47
-2
@@ -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<InvestmentInterestScheduleData.Row> 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);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-17
@@ -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);
|
||||
|
||||
+173
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user