feat(investment): total interest received and receivable report

This commit is contained in:
2026-10-23 11:26:09 +05:30
parent 52ca2bfc03
commit c3bb771d8a
3 changed files with 347 additions and 0 deletions
@@ -0,0 +1,79 @@
/**
* 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.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestReportData;
import org.apache.fineract.portfolio.investment.service.InvestmentInterestReportService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/interest-report")
@Component
@Tag(name = "Investment Interest Received & Receivable", description = "Interest received in a period and receivable at its end.")
@RequiredArgsConstructor
public class InvestmentInterestReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentInterestReportService reportService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Total Interest Received & Receivable", description = "fromDate / toDate dd-MM-yyyy; default 1 April of the "
+ "current financial year to today")
public InvestmentInterestReportData report(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate fyStart = LocalDate.of(today.getMonthValue() >= 4 ? today.getYear() : today.getYear() - 1, Month.APRIL, 1);
final LocalDate from = date(fromDate, fyStart);
final LocalDate to = date(toDate, today);
if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.dates", "The To date cannot be before the From date");
}
return this.reportService.report(from, to);
}
private static LocalDate date(final String value, final LocalDate fallback) {
if (value == null || value.isBlank()) {
return fallback;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY", value);
}
}
}
@@ -0,0 +1,64 @@
/**
* 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;
/**
* Total Interest Received & Receivable: per Govt Security / bond deal and FD, the interest received in a period and
* the interest earned up to its last day that has not come in yet.
*/
@Getter
@Builder
public class InvestmentInterestReportData {
private final String fromDate;
private final String toDate;
private final BigDecimal totalReceived;
private final BigDecimal totalReceivable;
private final BigDecimal totalProvisioned;
private final BigDecimal total;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** SECURITY or FD. */
private final String instrument;
private final Long refId;
private final String dealNo;
/** Script, or bank and certificate of an FD. */
private final String particulars;
private final String status;
/** Face value held, or the FD principal. */
private final BigDecimal principal;
private final BigDecimal rate;
/** Interest that came in during the period (coupons, FD payouts, interest paid at closure), gross of TDS. */
private final BigDecimal received;
/** Interest earned up to the To date and not yet received. */
private final BigDecimal receivable;
/** Part of the receivable already booked by 31 Mar / 30 Sep provisions (Interest Receivable 100603). */
private final BigDecimal provisioned;
private final BigDecimal total;
}
}
@@ -0,0 +1,204 @@
/**
* 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.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestReportData;
import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterest;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPosting;
import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPostingRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Total Interest Received &amp; Receivable. Received: interest that came in during the period. Receivable: interest
* earned up to the To date and not received yet — coupons / payouts already due but not posted, plus what has accrued
* since the last one — with the part already booked by the 31 Mar / 30 Sep provisions shown beside it.
*/
@Service
@RequiredArgsConstructor
public class InvestmentInterestReportService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final String COUPON = "COUPON";
private static final String PROVISION = "PROVISION";
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentInterestPostingRepository postingRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final InvestmentFdRepository fdRepository;
private final InvestmentFdInterestRepository fdInterestRepository;
@Transactional(readOnly = true)
public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) {
final List<InvestmentInterestReportData.Row> rows = new ArrayList<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
continue;
}
security(p, from, to, rows);
}
for (final InvestmentFd f : this.fdRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
fd(f, from, to, rows);
}
final BigDecimal received = sum(rows, InvestmentInterestReportData.Row::getReceived);
final BigDecimal receivable = sum(rows, InvestmentInterestReportData.Row::getReceivable);
return InvestmentInterestReportData.builder().fromDate(from.format(DATE)).toDate(to.format(DATE)).totalReceived(received)
.totalReceivable(receivable).totalProvisioned(sum(rows, InvestmentInterestReportData.Row::getProvisioned))
.total(received.add(receivable)).rows(rows).build();
}
private void security(final InvestmentPurchase p, final LocalDate from, final LocalDate to,
final List<InvestmentInterestReportData.Row> rows) {
final List<InvestmentInterestPosting> posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(p.getId());
BigDecimal received = BigDecimal.ZERO;
BigDecimal provisioned = BigDecimal.ZERO;
LocalDate lastCouponPosted = null;
for (final InvestmentInterestPosting x : posted) {
if (COUPON.equals(x.getLineType()) && inPeriod(x.getIntDate(), from, to)) {
received = received.add(zero(x.getGrossInterest()));
}
if (!x.getIntDate().isAfter(to)) {
// A coupon clears what the provisions before it booked; each provision adds the interest earned since.
if (COUPON.equals(x.getLineType())) {
provisioned = BigDecimal.ZERO;
lastCouponPosted = x.getIntDate();
} else if (PROVISION.equals(x.getLineType())) {
provisioned = provisioned.add(zero(x.getAmount()));
}
}
}
// A sale clears the receivable share of the units sold; units sold after the To date were still held on it.
BigDecimal soldAfterTo = BigDecimal.ZERO;
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (!InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus())) {
continue;
}
if (s.getSettlementDate().isAfter(to)) {
soldAfterTo = soldAfterTo.add(s.getUnits());
} else if (lastCouponPosted == null || s.getSettlementDate().isAfter(lastCouponPosted)) {
provisioned = provisioned.subtract(zero(s.getReceivableCleared()));
}
}
provisioned = provisioned.max(BigDecimal.ZERO);
BigDecimal receivable = BigDecimal.ZERO;
final BigDecimal face = p.getBalanceUnits().add(soldAfterTo).multiply(p.getScript().getFaceValuePerUnit());
final boolean heldOnTo = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus()) || soldAfterTo.signum() > 0;
if (heldOnTo && !p.getSettlementDate().isAfter(to)) {
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
LocalDate lastCoupon = p.getLastIntDate();
for (final InvestmentInterestCalculator.ScheduleRow r : this.purchaseReadPlatformService.scheduleRows(p)) {
if (!COUPON.equals(r.type()) || r.date().isAfter(to)) {
continue;
}
lastCoupon = r.date();
final boolean done = posted.stream().anyMatch(x -> COUPON.equals(x.getLineType()) && x.getIntDate().equals(r.date()));
if (!done) {
receivable = receivable.add(r.grossInterest());
}
}
if (lastCoupon != null && rate.signum() > 0) {
receivable = receivable.add(
InvestmentInterestCalculator.interestForDays(face, rate, InvestmentInterestCalculator.days360(lastCoupon, to)));
}
} else {
provisioned = BigDecimal.ZERO;
}
add(rows, "SECURITY", p.getId(), p.getDealNo(), p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(), p.getStatus(),
face.signum() > 0 ? face : p.getFaceValue(), p.getIntRate(), received, receivable, provisioned);
}
private void fd(final InvestmentFd f, final LocalDate from, final LocalDate to, final List<InvestmentInterestReportData.Row> rows) {
final List<InvestmentFdInterest> posted = this.fdInterestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId());
BigDecimal received = BigDecimal.ZERO;
BigDecimal provisioned = BigDecimal.ZERO;
LocalDate lastPaid = f.getDealDate();
for (final InvestmentFdInterest x : posted) {
final boolean payout = InvestmentFdApiConstants.LINE_PAYOUT.equals(x.getLineType());
if (payout && inPeriod(x.getIntDate(), from, to)) {
received = received.add(zero(x.getGrossInterest()));
}
if (!x.getIntDate().isAfter(to)) {
if (payout) {
provisioned = provisioned.subtract(zero(x.getAlreadyBooked()));
lastPaid = x.getIntDate();
} else if (InvestmentFdApiConstants.LINE_PROVISION.equals(x.getLineType())) {
provisioned = provisioned.add(zero(x.getAmount()));
}
}
}
if (f.getClosedOnDate() != null && inPeriod(f.getClosedOnDate(), from, to)) {
received = received.add(zero(f.getClosureInterest()));
}
BigDecimal receivable = BigDecimal.ZERO;
final boolean openOnTo = !f.getDealDate().isAfter(to) && (f.getClosedOnDate() == null || f.getClosedOnDate().isAfter(to));
if (openOnTo) {
final LocalDate end = f.getMaturityDate().isBefore(to) ? f.getMaturityDate() : to;
receivable = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, end, f.getCalcType(), f.getPayout());
} else {
provisioned = BigDecimal.ZERO;
}
add(rows, "FD", f.getId(), f.getDealNo(), f.getParty().getPartyName() + " · " + f.getCertNo(), f.getStatus(), f.getPrincipal(),
f.getIntRate(), received, receivable, provisioned.max(BigDecimal.ZERO));
}
private static void add(final List<InvestmentInterestReportData.Row> rows, final String instrument, final Long refId,
final String dealNo, final String particulars, final String status, final BigDecimal principal, final BigDecimal rate,
final BigDecimal received, final BigDecimal receivable, final BigDecimal provisioned) {
if (received.signum() == 0 && receivable.signum() == 0) {
return;
}
rows.add(InvestmentInterestReportData.Row.builder().instrument(instrument).refId(refId).dealNo(dealNo).particulars(particulars)
.status(status).principal(principal).rate(rate).received(received).receivable(receivable).provisioned(provisioned)
.total(received.add(receivable)).build());
}
private static boolean inPeriod(final LocalDate d, final LocalDate from, final LocalDate to) {
return d != null && !d.isBefore(from) && !d.isAfter(to);
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private static BigDecimal sum(final List<InvestmentInterestReportData.Row> rows,
final java.util.function.Function<InvestmentInterestReportData.Row, BigDecimal> f) {
return rows.stream().map(f).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}