From 13ad567a8be59400134ae86cc13726d588c37d35 Mon Sep 17 00:00:00 2001 From: anshul Date: Fri, 23 Oct 2026 12:39:53 +0530 Subject: [PATCH] feat(investment): bucket wise investment report by time left to maturity --- .../InvestmentBucketReportApiResource.java | 66 ++++++++ .../data/InvestmentBucketReportData.java | 80 +++++++++ .../InvestmentBucketReportService.java | 153 ++++++++++++++++++ 3 files changed, 299 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentBucketReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentBucketReportData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentBucketReportService.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentBucketReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentBucketReportApiResource.java new file mode 100644 index 000000000..2506728d6 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentBucketReportApiResource.java @@ -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); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentBucketReportData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentBucketReportData.java new file mode 100644 index 000000000..15c8bd7c0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentBucketReportData.java @@ -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 buckets; + private final Bucket total; + private final List 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentBucketReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentBucketReportService.java new file mode 100644 index 000000000..a17461068 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentBucketReportService.java @@ -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 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 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 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 rows) { + final List govt = rows.stream().filter(r -> GOVT.equals(r.getInstrument())).toList(); + final List bond = rows.stream().filter(r -> BOND.equals(r.getInstrument())).toList(); + final List 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 rows, + final java.util.function.Function f) { + return rows.stream().map(f).reduce(BigDecimal.ZERO, BigDecimal::add); + } +}