feat(investment): due date diary and keep dropped deals so their numbers are not reused
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

This commit is contained in:
2026-10-22 17:34:41 +05:30
parent 9b1e381e07
commit 3ef5a684ae
8 changed files with 288 additions and 6 deletions
@@ -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);
}
}
}
@@ -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";
@@ -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<Row> 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;
}
}
@@ -32,5 +32,5 @@ public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPu
@Query("select p from InvestmentPurchase p where p.id = :purchaseId")
Optional<InvestmentPurchase> findByIdWithLock(@Param("purchaseId") Long purchaseId);
boolean existsByScriptId(Long scriptId);
boolean existsByScriptIdAndStatusNot(Long scriptId, String status);
}
@@ -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<String> 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<InvestmentDiaryData.Row> 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<InvestmentDiaryData.Row> 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());
}
}
@@ -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());
}
@@ -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<InvestmentPurchaseData> 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) {
@@ -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();
}