From c3bb771d8a5b9da98d6f1d12a837f56daa5eccd4 Mon Sep 17 00:00:00 2001 From: anshul Date: Fri, 23 Oct 2026 11:26:09 +0530 Subject: [PATCH] feat(investment): total interest received and receivable report --- .../InvestmentInterestReportApiResource.java | 79 +++++++ .../data/InvestmentInterestReportData.java | 64 ++++++ .../InvestmentInterestReportService.java | 204 ++++++++++++++++++ 3 files changed, 347 insertions(+) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestReportApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestReportData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestReportApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestReportApiResource.java new file mode 100644 index 000000000..a5aabbe79 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestReportApiResource.java @@ -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); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestReportData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestReportData.java new file mode 100644 index 000000000..9a8fec678 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestReportData.java @@ -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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java new file mode 100644 index 000000000..b165fe15b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java @@ -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 & 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 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 rows) { + final List 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 rows) { + final List 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 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 rows, + final java.util.function.Function f) { + return rows.stream().map(f).reduce(BigDecimal.ZERO, BigDecimal::add); + } +}