feat(investment): bucket wise investment report by time left to maturity

This commit is contained in:
2026-10-23 12:39:53 +05:30
parent c3bb771d8a
commit 13ad567a8b
3 changed files with 299 additions and 0 deletions
@@ -0,0 +1,66 @@
/**
* 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.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.InvestmentBucketReportData;
import org.apache.fineract.portfolio.investment.service.InvestmentBucketReportService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/bucket-report")
@Component
@Tag(name = "Investment Bucket Wise", description = "Investments held on a date, split by the time left to maturity.")
@RequiredArgsConstructor
public class InvestmentBucketReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentBucketReportService reportService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Bucket Wise Investment", description = "asOnDate dd-MM-yyyy; default today")
public InvestmentBucketReportData report(@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
if (asOnDate == null || asOnDate.isBlank()) {
return this.reportService.report(DateUtils.getBusinessLocalDate());
}
try {
return this.reportService.report(LocalDate.parse(asOnDate.trim(), DATE));
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY",
asOnDate);
}
}
}
@@ -0,0 +1,80 @@
/**
* 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;
/**
* Bucket Wise Investment: Govt Securities, bonds and FDs held on a date, split by the time left to maturity (up to 1
* year, over 1 to 3, over 3 to 5, over 5 years).
*/
@Getter
@Builder
public class InvestmentBucketReportData {
private final String asOnDate;
private final List<Bucket> buckets;
private final Bucket total;
private final List<Row> rows;
@Getter
@Builder
public static class Bucket {
private final String code;
private final String label;
private final int govtCount;
private final BigDecimal govtFaceValue;
private final BigDecimal govtBookValue;
private final int bondCount;
private final BigDecimal bondFaceValue;
private final BigDecimal bondBookValue;
private final int fdCount;
private final BigDecimal fdPrincipal;
/** Book value of securities and bonds plus FD principal. */
private final BigDecimal totalAmount;
}
@Getter
@Builder
public static class Row {
/** Code of the bucket the holding falls in. */
private final String bucket;
/** GOVT_SECURITY, BOND (Non-SLR and other bonds) 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;
/** HTM / AFS / HFT of a security; blank for an FD. */
private final String category;
private final BigDecimal rate;
private final String maturityDate;
/** Days from the as-on date to maturity; negative when maturity has passed but it is not yet redeemed or closed. */
private final long daysToMaturity;
/** Face value held, or the FD principal. */
private final BigDecimal faceValue;
/** Cost less what sales, amortisation and write-offs took off, or the FD principal. */
private final BigDecimal bookValue;
}
}
@@ -0,0 +1,153 @@
/**
* 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.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentBucketReportData;
import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
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;
/**
* Bucket Wise Investment. Every Govt Security, bond and FD held on the as-on date goes into a bucket by the time left
* to its maturity; one already past maturity but not yet redeemed or closed counts in the first bucket.
*/
@Service
@RequiredArgsConstructor
public class InvestmentBucketReportService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final String GOVT = "GOVT_SECURITY";
private static final String BOND = "BOND";
private static final String FD = "FD";
private static final String[][] BUCKETS = { { "UPTO_1Y", "Up to 1 year" }, { "1Y_3Y", "Over 1 year to 3 years" },
{ "3Y_5Y", "Over 3 years to 5 years" }, { "ABOVE_5Y", "Over 5 years" } };
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentFdRepository fdRepository;
@Transactional(readOnly = true)
public InvestmentBucketReportData report(final LocalDate asOn) {
final List<InvestmentBucketReportData.Row> rows = new ArrayList<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "maturityDate", "id"))) {
security(p, asOn, rows);
}
for (final InvestmentFd f : this.fdRepository.findAll(Sort.by(Sort.Direction.ASC, "maturityDate", "id"))) {
final boolean held = !f.getDealDate().isAfter(asOn) && (f.getClosedOnDate() == null || f.getClosedOnDate().isAfter(asOn));
if (held) {
rows.add(row(asOn, FD, f.getId(), f.getDealNo(), f.getParty().getPartyName() + " · " + f.getCertNo(), null, f.getIntRate(),
f.getMaturityDate(), f.getPrincipal(), f.getPrincipal()));
}
}
rows.sort(Comparator.comparingLong(InvestmentBucketReportData.Row::getDaysToMaturity));
final List<InvestmentBucketReportData.Bucket> buckets = new ArrayList<>();
for (final String[] b : BUCKETS) {
buckets.add(bucket(b[0], b[1], rows.stream().filter(r -> r.getBucket().equals(b[0])).toList()));
}
return InvestmentBucketReportData.builder().asOnDate(asOn.format(DATE)).buckets(buckets).total(bucket("TOTAL", "Total", rows))
.rows(rows).build();
}
/**
* A security counts when settled on or before the as-on date with units still held on it: units sold or redeemed
* after that date are added back, and book value is cost less what sales, amortisation and write-offs settled by
* then took off.
*/
private void security(final InvestmentPurchase p, final LocalDate asOn, final List<InvestmentBucketReportData.Row> rows) {
final String bondType = p.getBondType() != null ? p.getBondType() : p.getScript().getBondType();
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(bondType) || p.getMaturityDate() == null
|| InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus()) || p.getSettlementDate().isAfter(asOn)) {
return;
}
BigDecimal units = p.getBalanceUnits();
BigDecimal book = p.getBookValue();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (!InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus())) {
continue;
}
if (s.getSettlementDate().isAfter(asOn)) {
units = units.add(s.getUnits());
} else {
book = book.subtract(s.getBookValueSold() == null ? BigDecimal.ZERO : s.getBookValueSold());
}
}
if (units.signum() <= 0) {
return;
}
rows.add(row(asOn, GOVT.equals(bondType) ? GOVT : BOND, p.getId(), p.getDealNo(),
p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(), p.getCategory(), p.getIntRate(), p.getMaturityDate(),
units.multiply(p.getScript().getFaceValuePerUnit()), book));
}
private static InvestmentBucketReportData.Row row(final LocalDate asOn, final String instrument, final Long refId, final String dealNo,
final String particulars, final String category, final BigDecimal rate, final LocalDate maturity, final BigDecimal face,
final BigDecimal book) {
return InvestmentBucketReportData.Row.builder().bucket(bucketOf(asOn, maturity)).instrument(instrument).refId(refId).dealNo(dealNo)
.particulars(particulars).category(category).rate(rate).maturityDate(maturity.format(DATE))
.daysToMaturity(ChronoUnit.DAYS.between(asOn, maturity)).faceValue(face).bookValue(book).build();
}
private static String bucketOf(final LocalDate asOn, final LocalDate maturity) {
if (!maturity.isAfter(asOn.plusYears(1))) {
return BUCKETS[0][0];
}
if (!maturity.isAfter(asOn.plusYears(3))) {
return BUCKETS[1][0];
}
return maturity.isAfter(asOn.plusYears(5)) ? BUCKETS[3][0] : BUCKETS[2][0];
}
private static InvestmentBucketReportData.Bucket bucket(final String code, final String label,
final List<InvestmentBucketReportData.Row> rows) {
final List<InvestmentBucketReportData.Row> govt = rows.stream().filter(r -> GOVT.equals(r.getInstrument())).toList();
final List<InvestmentBucketReportData.Row> bond = rows.stream().filter(r -> BOND.equals(r.getInstrument())).toList();
final List<InvestmentBucketReportData.Row> fd = rows.stream().filter(r -> FD.equals(r.getInstrument())).toList();
final BigDecimal govtBook = sum(govt, InvestmentBucketReportData.Row::getBookValue);
final BigDecimal bondBook = sum(bond, InvestmentBucketReportData.Row::getBookValue);
final BigDecimal fdPrincipal = sum(fd, InvestmentBucketReportData.Row::getBookValue);
return InvestmentBucketReportData.Bucket.builder().code(code).label(label).govtCount(govt.size())
.govtFaceValue(sum(govt, InvestmentBucketReportData.Row::getFaceValue)).govtBookValue(govtBook).bondCount(bond.size())
.bondFaceValue(sum(bond, InvestmentBucketReportData.Row::getFaceValue)).bondBookValue(bondBook).fdCount(fd.size())
.fdPrincipal(fdPrincipal).totalAmount(govtBook.add(bondBook).add(fdPrincipal)).build();
}
private static BigDecimal sum(final List<InvestmentBucketReportData.Row> rows,
final java.util.function.Function<InvestmentBucketReportData.Row, BigDecimal> f) {
return rows.stream().map(f).reduce(BigDecimal.ZERO, BigDecimal::add);
}
}