From 3ef5a684aeefd8263c5d2e19502b56fe2b889056 Mon Sep 17 00:00:00 2001 From: anshul Date: Thu, 22 Oct 2026 17:34:41 +0530 Subject: [PATCH] feat(investment): due date diary and keep dropped deals so their numbers are not reused --- .../api/InvestmentDiaryApiResource.java | 78 ++++++++++ .../api/InvestmentPurchaseApiConstants.java | 2 + .../investment/data/InvestmentDiaryData.java | 61 ++++++++ .../domain/InvestmentPurchaseRepository.java | 2 +- .../service/InvestmentDiaryService.java | 136 ++++++++++++++++++ ...estmentMasterWritePlatformServiceImpl.java | 3 +- ...InvestmentPurchaseReadPlatformService.java | 6 +- ...tmentPurchaseWritePlatformServiceImpl.java | 6 +- 8 files changed, 288 insertions(+), 6 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDiaryApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDiaryData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDiaryService.java diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDiaryApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDiaryApiResource.java new file mode 100644 index 000000000..ad8ca775b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDiaryApiResource.java @@ -0,0 +1,78 @@ +/** + * 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.InvestmentDiaryData; +import org.apache.fineract.portfolio.investment.service.InvestmentDiaryService; +import org.springframework.stereotype.Component; + +@Path("/v1/investment/diary") +@Component +@Tag(name = "Investment Due Date Diary", description = "Coupons, provisions, amortisation, FD payouts and maturities due in a period.") +@RequiredArgsConstructor +public class InvestmentDiaryApiResource { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + /** Default window when no dates are given: today and the next 30 days. */ + private static final int DEFAULT_DAYS = 30; + + private final PlatformSecurityContext context; + private final InvestmentDiaryService diaryService; + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Due Date Diary for all investments", description = "fromDate / toDate dd-MM-yyyy; default today to 30 days ahead") + public InvestmentDiaryData diary(@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 from = date(fromDate, today); + final LocalDate to = date(toDate, from.plusDays(DEFAULT_DAYS)); + if (to.isBefore(from)) { + throw new GeneralPlatformDomainRuleException("error.msg.investment.diary.dates", "The To date cannot be before the From date"); + } + return this.diaryService.diary(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.diary.date.invalid", "Enter the date as DD-MM-YYYY", value); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java index d5d99b7fb..4610d818b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java @@ -39,6 +39,8 @@ public final class InvestmentPurchaseApiConstants { public static final String STATUS_SOLD = "SOLD"; /** Repaid at maturity. */ public static final String STATUS_REDEEMED = "REDEEMED"; + /** Dropped before settlement; kept so its deal and certificate numbers are never given out again. */ + public static final String STATUS_DROPPED = "DROPPED"; /** GLs the settlement entry debits besides the script's investment GL. */ public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDiaryData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDiaryData.java new file mode 100644 index 000000000..f379a983f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDiaryData.java @@ -0,0 +1,61 @@ +/** + * 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; + +/** + * Due Date Diary: every dated event of the active investments in a period — Govt Security / bond coupons and 31 Mar / + * 30 Sep provisions, maturities, premium amortisation, and FD provisions, payouts and maturities. + */ +@Getter +@Builder +public class InvestmentDiaryData { + + private final String fromDate; + private final String toDate; + private final String asOnDate; + private final int pendingCount; + private final int overdueCount; + /** Money expected in the period from coupons, FD payouts and maturities not yet received. */ + private final BigDecimal expectedInflow; + private final List rows; + + @Getter + @Builder + public static class Row { + + private final String date; + /** COUPON, PROVISION, MATURITY, AMORTISATION (Govt Security / bond); FD_PROVISION, FD_PAYOUT, FD_MATURITY. */ + private final String kind; + /** SECURITY or FD — which screen the entry belongs to. */ + 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 BigDecimal amount; + /** POSTED, OVERDUE, DUE (today), UPCOMING. */ + private final String status; + private final String voucherNo; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java index 9865b6c60..94c81b73f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java @@ -32,5 +32,5 @@ public interface InvestmentPurchaseRepository extends JpaRepository findByIdWithLock(@Param("purchaseId") Long purchaseId); - boolean existsByScriptId(Long scriptId); + boolean existsByScriptIdAndStatusNot(Long scriptId, String status); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDiaryService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDiaryService.java new file mode 100644 index 000000000..bf5149525 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDiaryService.java @@ -0,0 +1,136 @@ +/** + * 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.Comparator; +import java.util.List; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants; +import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; +import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData; +import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData; +import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; +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.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Due Date Diary: the dated events of every active investment between two dates, taken from the same schedules the + * entry screens use, so the diary always agrees with Interest Entry, Amortize Entry and the FD screens. + */ +@Service +@RequiredArgsConstructor +public class InvestmentDiaryService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + private static final String SECURITY = "SECURITY"; + private static final String FD = "FD"; + /** Events that bring money in; provisions and amortisation are book entries only. */ + private static final Set CASH_KINDS = Set.of("COUPON", "MATURITY", "FD_PAYOUT", "FD_MATURITY"); + + private final InvestmentPurchaseRepository purchaseRepository; + private final InvestmentFdRepository fdRepository; + private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService; + private final InvestmentAmortisationService amortisationService; + private final InvestmentFdInterestService fdInterestService; + + @Transactional(readOnly = true) + public InvestmentDiaryData diary(final LocalDate from, final LocalDate to) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final List rows = new ArrayList<>(); + + for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { + if (!InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus())) { + continue; + } + final InvestmentInterestScheduleData s = this.purchaseReadPlatformService.retrieveInterestSchedule(p.getId(), null); + final String particulars = s.getScriptCode() + " " + s.getScriptDesc(); + for (final InvestmentInterestScheduleData.Row r : s.getRows()) { + // A coupon shows the cash coming in (the full coupon); a provision shows what it books. + final BigDecimal amount = "COUPON".equals(r.getType()) ? r.getGrossInterest() : r.getAmount(); + add(rows, from, to, today, r.getIntDate(), r.getType(), SECURITY, p.getId(), p.getDealNo(), particulars, amount, + r.isPosted(), r.getVoucherNo()); + } + if (p.getMaturityDate() != null) { + add(rows, from, to, today, s.getMaturityDate(), "MATURITY", SECURITY, p.getId(), p.getDealNo(), particulars, + s.getFaceValue(), false, null); + } + if (p.getCategory() != null && p.getCategory().startsWith("HTM") && p.getPremiumDiscount() != null + && p.getPremiumDiscount().signum() > 0) { + final InvestmentAmortisationData a = this.amortisationService.schedule(p.getId()); + for (final InvestmentAmortisationData.Row r : a.getRows()) { + if ("YEAR".equals(r.getKind()) && r.getAmount() != null && r.getAmount().signum() > 0) { + add(rows, from, to, today, r.getFyEnd(), "AMORTISATION", SECURITY, p.getId(), p.getDealNo(), particulars, + r.getAmount(), r.isPosted(), r.getVoucherNo()); + } + } + } + } + + for (final InvestmentFd f : this.fdRepository.findByStatusOrderByIdAsc(InvestmentFdApiConstants.STATUS_ACTIVE)) { + final InvestmentFdScheduleData s = this.fdInterestService.schedule(f.getId()); + final String particulars = f.getParty().getPartyName() + " · " + f.getCertNo(); + for (final InvestmentFdScheduleData.Row r : s.getRows()) { + final boolean maturity = InvestmentFdApiConstants.LINE_MATURITY.equals(r.getLineType()); + // Maturity: the maturity amount; a payout: the interest paid (net of TDS); a provision: what it books. + final BigDecimal amount = maturity ? f.getExpectedMaturityAmount() + : InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType()) + ? r.getGrossInterest().subtract(r.getTds() == null ? BigDecimal.ZERO : r.getTds()) + : r.getAmount(); + add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars, amount, + r.isPosted(), r.getVoucherNo()); + } + } + + rows.sort(Comparator.comparing((InvestmentDiaryData.Row r) -> LocalDate.parse(r.getDate(), DATE)) + .thenComparing(InvestmentDiaryData.Row::getInstrument).thenComparing(InvestmentDiaryData.Row::getDealNo)); + final BigDecimal inflow = rows.stream().filter(r -> CASH_KINDS.contains(r.getKind()) && !"POSTED".equals(r.getStatus())) + .map(InvestmentDiaryData.Row::getAmount).filter(a -> a != null).reduce(BigDecimal.ZERO, BigDecimal::add); + return InvestmentDiaryData.builder().fromDate(from.format(DATE)).toDate(to.format(DATE)).asOnDate(today.format(DATE)) + .pendingCount((int) rows.stream().filter(r -> !"POSTED".equals(r.getStatus())).count()) + .overdueCount((int) rows.stream().filter(r -> "OVERDUE".equals(r.getStatus())).count()).expectedInflow(inflow).rows(rows) + .build(); + } + + private static void add(final List rows, final LocalDate from, final LocalDate to, final LocalDate today, + final String date, final String kind, final String instrument, final Long refId, final String dealNo, final String particulars, + final BigDecimal amount, final boolean posted, final String voucherNo) { + if (date == null) { + return; + } + final LocalDate d = LocalDate.parse(date, DATE); + if (d.isBefore(from) || d.isAfter(to)) { + return; + } + final String status = posted ? "POSTED" : d.isBefore(today) ? "OVERDUE" : d.isEqual(today) ? "DUE" : "UPCOMING"; + rows.add(InvestmentDiaryData.Row.builder().date(date).kind(kind).instrument(instrument).refId(refId).dealNo(dealNo) + .particulars(particulars).amount(amount).status(status).voucherNo(voucherNo).build()); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java index d114c4a76..70ac47239 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java @@ -261,7 +261,8 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste && !Objects.equals(command.localDateValueOfParameterNamed(MATURITY_DATE), s.getMaturityDate())) { changed.add("maturity date"); } - if (!changed.isEmpty() && this.purchaseRepository.existsByScriptId(s.getId())) { + if (!changed.isEmpty() && this.purchaseRepository.existsByScriptIdAndStatusNot(s.getId(), + org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_DROPPED)) { throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.terms.in.use", "Script " + s.getScriptCode() + " is already used by deals, so its " + String.join(", ", changed) + " cannot be changed", s.getScriptCode()); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java index e51b73fa1..9b550c75c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java @@ -54,9 +54,11 @@ public class InvestmentPurchaseReadPlatformService { private final InvestmentInterestPostingRepository postingRepository; private final GLAccountRepositoryWrapper glAccountRepository; - /** All deals, newest first — the Holdings register. */ + /** All deals, newest first — the Holdings register. Dropped deals only hold their numbers and are left out. */ public List retrieveAll() { - return this.purchaseRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentPurchaseData::from).toList(); + return this.purchaseRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream() + .filter(p -> !InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())).map(InvestmentPurchaseData::from) + .toList(); } public InvestmentPurchaseData retrieveOne(final Long purchaseId) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index fd33e0294..feef0ae32 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -40,6 +40,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_DROPPED; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE; @@ -173,8 +174,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) { throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped"); } - this.purchaseRepository.delete(p); - this.purchaseRepository.flush(); + // Kept as DROPPED rather than deleted, so the next deal does not reuse its deal or certificate number. + p.setStatus(STATUS_DROPPED); + this.purchaseRepository.saveAndFlush(p); return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build(); }