feat(investment): non-SLR bonds and mutual funds, UAT fixes
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 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 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 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-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / 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 - 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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 11:14:35 +05:30
co-authored by Claude Opus 5.5
parent 140c76dac9
commit 9bd1e5306f
29 changed files with 524 additions and 86 deletions
@@ -29,8 +29,6 @@ import jakarta.ws.rs.PathParam;
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.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
@@ -68,7 +66,7 @@ public class InvestmentAmortisationApiResource {
@Operation(summary = "Holdings due for amortisation on a 31 March (07 Amortize Yearly Process)", description = "fyEnd dd-MM-yyyy")
public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.amortisationService.due(LocalDate.parse(fyEnd.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy")));
return this.amortisationService.due(InvestmentApiDates.parse(fyEnd, null, "financial year end"));
}
@POST
@@ -61,6 +61,9 @@ public final class InvestmentApiConstants {
/** RBI: corporate bonds rated "A" or higher, CPs and CDs rated "A2" or higher. */
public static final Set<String> ELIGIBLE_RATINGS = Set.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "A1+", "A1", "A2+", "A2");
/** Short-term scale, for CPs and CDs (issued for up to one year); bonds carry the long-term scale. */
public static final Set<String> SHORT_TERM_RATINGS = Set.of("A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4");
/** Credit rating agencies registered with SEBI. */
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
"INFOMERICS");
@@ -0,0 +1,50 @@
/**
* 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 java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
/** Query-parameter dates of the Investment APIs: DD-MM-YYYY, strictly (31-02 is refused, not moved to month end). */
public final class InvestmentApiDates {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
private InvestmentApiDates() {}
/** The date in {@code value}, or {@code fallback} when it is blank; a malformed date is a clear 403, not a 500. */
public static LocalDate parse(final String value, final LocalDate fallback, final String name) {
if (value == null || value.isBlank()) {
if (fallback == null) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.date.required", "Enter the " + name + " as DD-MM-YYYY",
name);
}
return fallback;
}
try {
return LocalDate.parse(value.trim(), DATE);
} catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.date.invalid", "Enter the " + name + " as DD-MM-YYYY",
value);
}
}
}
@@ -29,6 +29,7 @@ import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -43,7 +44,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class InvestmentBucketReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
private final PlatformSecurityContext context;
private final InvestmentBucketReportService reportService;
@@ -56,11 +57,16 @@ public class InvestmentBucketReportApiResource {
if (asOnDate == null || asOnDate.isBlank()) {
return this.reportService.report(DateUtils.getBusinessLocalDate());
}
final LocalDate asOn;
try {
return this.reportService.report(LocalDate.parse(asOnDate.trim(), DATE));
asOn = 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);
}
if (asOn.isAfter(DateUtils.getBusinessLocalDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.future", "The as-on date cannot be after today");
}
return this.reportService.report(asOn);
}
}
@@ -31,10 +31,8 @@ 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.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -71,8 +69,7 @@ public class InvestmentDailyRateApiResource {
public InvestmentDailyRateData retrieveLatest(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@QueryParam("onDate") @Parameter(description = "dd-MM-yyyy") final String onDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
final LocalDate on = StringUtils.isBlank(onDate) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(onDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
final LocalDate on = InvestmentApiDates.parse(onDate, DateUtils.getBusinessLocalDate(), "date");
return this.readPlatformService.retrieveLatest(scriptId, on);
}
@@ -30,6 +30,7 @@ import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -44,7 +45,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class InvestmentInterestReportApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-uuuu").withResolverStyle(ResolverStyle.STRICT);
private final PlatformSecurityContext context;
private final InvestmentInterestReportService reportService;
@@ -57,9 +58,13 @@ public class InvestmentInterestReportApiResource {
@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.isAfter(today)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.future", "The To date cannot be after today");
}
// Without a From date the report runs from 1 April of the To date's financial year.
final LocalDate fyStart = LocalDate.of(to.getMonthValue() >= 4 ? to.getYear() : to.getYear() - 1, Month.APRIL, 1);
final LocalDate from = date(fromDate, fyStart);
if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.dates", "The To date cannot be before the From date");
}
@@ -32,7 +32,6 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
@@ -80,8 +79,7 @@ public class InvestmentPurchaseApiResource {
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate date = StringUtils.isBlank(dealDate) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(dealDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
final LocalDate date = InvestmentApiDates.parse(dealDate, DateUtils.getBusinessLocalDate(), "deal date");
return this.nonSlrService.limits(date, scriptId, amount);
}
@@ -103,8 +101,7 @@ public class InvestmentPurchaseApiResource {
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null : InvestmentApiDates.parse(asOnDate, null, "as-on date");
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
}
@@ -32,7 +32,6 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
@@ -51,8 +50,6 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor
public class InvestmentSaleApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentSaleReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -78,7 +75,7 @@ public class InvestmentSaleApiResource {
@QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage,
@QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
final LocalDate deal = LocalDate.parse(dealDate.trim(), DATE);
final LocalDate deal = InvestmentApiDates.parse(dealDate, null, "deal date");
return this.readPlatformService.quoteSale(purchaseId, deal, StringUtils.defaultIfBlank(settlementType, "T+0"), units, saleRate,
brokerage, bankGlAccountId);
}
@@ -34,7 +34,10 @@ public class InvestmentFdDueData {
/** PROVISION, PAYOUT or MATURITY. */
private final String lineType;
private final String intDate;
/** What the bank credits (net of TDS); a provision line: what it books. */
private final BigDecimal amount;
private final BigDecimal grossInterest;
private final BigDecimal tds;
private final boolean postable;
private final boolean overdue;
}
@@ -50,7 +50,7 @@ public class InvestmentInterestReportData {
/** Script, or bank and certificate of an FD. */
private final String particulars;
private final String status;
/** Face value held, or the FD principal. */
/** Face value of the deal as bought, 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. */
@@ -18,6 +18,10 @@
*/
package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {}
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {
List<InvestmentSecurityTransfer> findByPurchaseIdOrderByTransferDateAscIdAsc(Long purchaseId);
}
@@ -0,0 +1,28 @@
/**
* 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.exception;
import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException;
public class InvestmentFdNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentFdNotFoundException(final Long id) {
super("error.msg.investmentfd.id.invalid", "FD " + id + " does not exist", id);
}
}
@@ -167,8 +167,9 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
final String bondType = create ? this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element) : existingBondType;
final boolean debt = bondType != null && DEBT_BOND_TYPES.contains(bondType);
// A debt security keeps its ISIN, coupon and issue date: required on create, and cannot be blanked on update.
final String isin = this.fromApiJsonHelper.extractStringNamed(ISIN, element);
if (create && debt) {
if (debt && (create || this.fromApiJsonHelper.parameterExists(ISIN, element))) {
v.reset().parameter(ISIN).value(isin).notBlank();
}
v.reset().parameter(ISIN).value(isin).ignoreIfNull().notExceedingLengthOf(20);
@@ -176,7 +177,7 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
if (this.fromApiJsonHelper.parameterExists(INT_RATE, element) || (create && debt)) {
final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element);
final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate);
if (create && debt) {
if (debt) {
rate.notNull();
}
rate.ignoreIfNull().zeroOrPositiveAmount();
@@ -190,7 +191,7 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
LocalDate issueDate = null;
if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) {
issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element);
if (create && debt) {
if (debt) {
v.reset().parameter(ISSUE_DATE).value(issueDate).notNull();
}
}
@@ -206,7 +207,8 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
validateOptionalString(element, v, COMPANY_NAME, 150);
validateOptionalString(element, v, CORP_CODE, 30);
validateRating(element, v, create && bondType != null && RATED_BOND_TYPES.contains(bondType));
validateRating(element, v, bondType != null && RATED_BOND_TYPES.contains(bondType)
&& (create || this.fromApiJsonHelper.parameterExists(RATING, element)));
validateAddress(element, v);
throwIfErrors(errors);
}
@@ -107,7 +107,10 @@ public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
} else {
v.reset().parameter(FACE_VALUE).value(amount(FACE_VALUE, element)).notNull().positiveAmount();
v.reset().parameter(PURCHASE_RATE).value(amount(PURCHASE_RATE, element)).notNull().positiveAmount();
v.reset().parameter(LAST_INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(LAST_INT_DATE, element)).notNull();
// Non-SLR has no interest scheme (Jitendra Sir): only a Govt Security needs its last coupon date.
if ("GOVT_SECURITY".equals(bondType)) {
v.reset().parameter(LAST_INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(LAST_INT_DATE, element)).notNull();
}
}
v.reset().parameter(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, element)).ignoreIfNull().zeroOrPositiveAmount();
@@ -36,6 +36,9 @@ 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.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransferRepository;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -58,6 +61,7 @@ public class InvestmentBucketReportService {
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentFdRepository fdRepository;
private final InvestmentSecurityTransferRepository transferRepository;
@Transactional(readOnly = true)
public InvestmentBucketReportData report(final LocalDate asOn) {
@@ -109,9 +113,13 @@ public class InvestmentBucketReportService {
if (units.signum() <= 0) {
return;
}
// A holding transferred after the as-on date was still the old security on that date.
final InvestmentScript script = this.transferRepository.findByPurchaseIdOrderByTransferDateAscIdAsc(p.getId()).stream()
.filter(t -> t.getTransferDate().isAfter(asOn)).findFirst().map(InvestmentSecurityTransfer::getFromScript)
.orElse(p.getScript());
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));
script.getScriptCode() + " " + script.getScriptDesc(), p.getCategory(), p.getIntRate(), p.getMaturityDate(),
units.multiply(script.getFaceValuePerUnit()), book));
}
private static InvestmentBucketReportData.Row row(final LocalDate asOn, final String instrument, final Long refId, final String dealNo,
@@ -31,6 +31,7 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
@@ -58,6 +59,20 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final LocalDate rateDate = command.localDateValueOfParameterNamed(RATE_DATE);
// A market rate is observed, so it cannot be dated after today, nor outside the security's life, nor for an
// inactive script.
if (!script.isActive()) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.script.inactive",
"Script " + script.getScriptCode() + " is inactive");
}
if (rateDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.future.date", "A rate cannot be dated after today");
}
if ((script.getIssueDate() != null && rateDate.isBefore(script.getIssueDate()))
|| (script.getMaturityDate() != null && rateDate.isAfter(script.getMaturityDate()))) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.outside.life",
"Script " + script.getScriptCode() + " is not running on that date (between its issue and maturity dates)");
}
if (this.dailyRateRepository.existsByScriptIdAndRateDate(scriptId, rateDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.duplicate",
"A rate for " + script.getScriptCode() + " on this date already exists. Drop it first to correct it.");
@@ -29,6 +29,7 @@ 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.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData;
@@ -37,13 +38,16 @@ 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;
/**
* 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.
* Due Date Diary: the dated events of every 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. Open holdings show
* what is posted and what is due; sold, redeemed and closed ones show what was posted and the redemption / closure.
*/
@Service
@RequiredArgsConstructor
@@ -60,6 +64,7 @@ public class InvestmentDiaryService {
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final InvestmentAmortisationService amortisationService;
private final InvestmentFdInterestService fdInterestService;
private final InvestmentSaleRepository saleRepository;
@Transactional(readOnly = true)
public InvestmentDiaryData diary(final LocalDate from, final LocalDate to) {
@@ -67,11 +72,29 @@ public class InvestmentDiaryService {
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())) {
if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
continue;
}
final boolean open = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus());
final InvestmentInterestScheduleData s = this.purchaseReadPlatformService.retrieveInterestSchedule(p.getId(), null);
final String particulars = s.getScriptCode() + " " + s.getScriptDesc();
if (!open) {
// Sold out or redeemed: only what was posted, and the redemption itself.
for (final InvestmentInterestScheduleData.Row r : s.getRows()) {
if (r.isPosted()) {
add(rows, from, to, today, r.getIntDate(), r.getType(), SECURITY, p.getId(), p.getDealNo(), particulars,
"COUPON".equals(r.getType()) ? r.getGrossInterest() : r.getAmount(), true, r.getVoucherNo());
}
}
for (final InvestmentSale x : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (InvestmentSaleApiConstants.STATUS_SETTLED.equals(x.getStatus()) && "REDEEM".equals(x.getSaleType())) {
add(rows, from, to, today, x.getSettlementDate().format(DATE), "MATURITY", SECURITY, p.getId(), p.getDealNo(),
particulars, x.getNetAmount(), true, x.getVoucherNo());
}
}
continue;
}
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();
@@ -94,16 +117,31 @@ public class InvestmentDiaryService {
}
}
for (final InvestmentFd f : this.fdRepository.findByStatusOrderByIdAsc(InvestmentFdApiConstants.STATUS_ACTIVE)) {
for (final InvestmentFd f : this.fdRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
final InvestmentFdScheduleData s = this.fdInterestService.schedule(f.getId());
final String particulars = f.getParty().getPartyName() + " · " + f.getCertNo();
if (f.getClosedOnDate() != null) {
// Closed (matured, premature or renewed): what was posted, and the closure with what the bank paid.
for (final InvestmentFdScheduleData.Row r : s.getRows()) {
if (r.isPosted()) {
final BigDecimal tds = r.getTds() == null ? BigDecimal.ZERO : r.getTds();
final boolean payout = InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType());
add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars,
payout ? r.getGrossInterest().subtract(tds) : r.getAmount(), true, r.getVoucherNo());
}
}
add(rows, from, to, today, f.getClosedOnDate().format(DATE), "FD_MATURITY", FD, f.getId(), f.getDealNo(),
particulars + " (" + String.valueOf(f.getClosureType()).toLowerCase() + ")", f.getClosureAmount(), true,
f.getClosureVoucherNo());
continue;
}
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();
// Maturity: the principal and the last interest (net of TDS) the bank credits; a payout: the interest paid
// (net of TDS); a provision: what it books.
final BigDecimal tds = r.getTds() == null ? BigDecimal.ZERO : r.getTds();
final BigDecimal amount = maturity ? f.getPrincipal().add(r.getGrossInterest()).subtract(tds)
: InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType()) ? r.getGrossInterest().subtract(tds) : r.getAmount();
add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars, amount,
r.isPosted(), r.getVoucherNo());
}
@@ -82,6 +82,7 @@ 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.exception.InvestmentFdNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -113,7 +114,10 @@ public class InvestmentFdClosureService {
public InvestmentFdClosureData quote(final Long fdId, final String type, final LocalDate closeDate, final BigDecimal receivedAmount,
final BigDecimal tds, final BigDecimal renewalAmount, final Long principalBankGlId, final Long interestBankGlId,
final Long diffBankGlId) {
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
if (f.getClosedOnDate() != null) {
throw InvestmentFdService.rule("already.closed", "FD " + f.getDealNo() + " is already closed (" + f.getStatus() + ")");
}
final String t = CLOSURE_PREMATURE.equals(type) || CLOSURE_RENEWAL.equals(type) ? type : CLOSURE_MATURITY;
final LocalDate date = CLOSURE_PREMATURE.equals(t) ? (closeDate == null ? DateUtils.getBusinessLocalDate() : closeDate) : f.getMaturityDate();
final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount);
@@ -360,7 +364,7 @@ public class InvestmentFdClosureService {
private InvestmentFd active(final Long fdId) {
final InvestmentFd f = this.fdRepository.findByIdWithLock(fdId)
.orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
.orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
if (!STATUS_ACTIVE.equals(f.getStatus())) {
throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase());
}
@@ -68,6 +68,7 @@ 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.exception.InvestmentFdNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -97,7 +98,7 @@ public class InvestmentFdInterestService {
@Transactional(readOnly = true)
public InvestmentFdScheduleData schedule(final Long fdId) {
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
final List<Line> lines = lines(f);
final LocalDate today = DateUtils.getBusinessLocalDate();
final Optional<Line> next = lines.stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type())).findFirst();
@@ -110,7 +111,9 @@ public class InvestmentFdInterestService {
.glTransactionId(l.posted() == null ? null : l.posted().getGlTransactionId())
.postable(STATUS_ACTIVE.equals(f.getStatus()) && next.isPresent() && next.get() == l && !l.date().isAfter(today)).build());
}
return InvestmentFdScheduleData.builder().fd(InvestmentFdService.data(f)).provisioned(provisioned(f)).rows(rows).build();
// A closed FD's provisions were cleared at closure.
return InvestmentFdScheduleData.builder().fd(InvestmentFdService.data(f))
.provisioned(f.getClosedOnDate() == null ? provisioned(f) : BigDecimal.ZERO).rows(rows).build();
}
/** For every active FD: its next interest voucher, or its maturity, when due now or within {@code withinDays}. */
@@ -123,14 +126,25 @@ public class InvestmentFdInterestService {
lines(f).stream().filter(l -> l.posted() == null).findFirst().filter(l -> !l.date().isAfter(upTo))
.ifPresent(l -> due.add(InvestmentFdDueData.builder().fdId(f.getId()).dealNo(f.getDealNo())
.partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).lineType(l.type()).intDate(l.date().format(DATE))
.amount(LINE_MATURITY.equals(l.type()) ? f.getExpectedMaturityAmount() : l.amount())
// What the bank credits (maturity: principal + interest - TDS; payout: interest - TDS).
.amount(LINE_MATURITY.equals(l.type()) ? f.getPrincipal().add(l.gross()).subtract(zero(l.tds()))
: LINE_PAYOUT.equals(l.type()) ? l.gross().subtract(zero(l.tds())) : l.amount())
.grossInterest(l.gross()).tds(l.tds())
.postable(!l.date().isAfter(today)).overdue(l.date().isBefore(today)).build()));
}
return due;
}
/** Refuses a closure on {@code date} while an interest line before it is still unposted. */
/**
* Refuses a closure on {@code date} while an interest line before it is still unposted, or when an interest line
* dated after it is already posted (the closure would reverse a provision the books do not hold yet on that date).
*/
void requirePostedBefore(final InvestmentFd f, final LocalDate date) {
final List<InvestmentFdInterest> posted = this.interestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId());
if (!posted.isEmpty() && posted.get(posted.size() - 1).getIntDate().isAfter(date)) {
throw InvestmentFdService.rule("interest.posted.after", "Interest of FD " + f.getDealNo() + " is already posted up to "
+ posted.get(posted.size() - 1).getIntDate().format(DATE) + "; close it on or after that date");
}
lines(f).stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type()) && l.date().isBefore(date)).findFirst()
.ifPresent(l -> {
throw InvestmentFdService.rule("interest.pending", "Post the " + l.type().toLowerCase() + " of "
@@ -142,7 +156,7 @@ public class InvestmentFdInterestService {
public CommandProcessingResult post(final Long fdId, final JsonCommand command) {
validate(command.json());
final InvestmentFd f = this.fdRepository.findByIdWithLock(fdId)
.orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
.orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
if (!STATUS_ACTIVE.equals(f.getStatus())) {
throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase());
}
@@ -274,7 +288,8 @@ public class InvestmentFdInterestService {
if (f.getClosedOnDate() == null) {
final BigDecimal last = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, f.getMaturityDate(),
f.getCalcType(), f.getPayout());
out.add(new Line(LINE_MATURITY, f.getMaturityDate(), lastPaid, last, sincePaid, null, last.subtract(sincePaid), null));
final BigDecimal tds = InvestmentFdService.tdsOnGross(f.getParty(), last);
out.add(new Line(LINE_MATURITY, f.getMaturityDate(), lastPaid, last, sincePaid, tds, last.subtract(sincePaid), null));
}
return out;
}
@@ -80,6 +80,7 @@ import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentFdNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate;
@@ -115,7 +116,7 @@ public class InvestmentFdService {
@Transactional(readOnly = true)
public InvestmentFdData retrieveOne(final Long fdId) {
return data(this.fdRepository.findById(fdId).orElseThrow(() -> rule("not.found", "FD " + fdId + " does not exist")));
return data(this.fdRepository.findById(fdId).orElseThrow(() -> new InvestmentFdNotFoundException(fdId)));
}
/** What an FD would earn and where it stands against the RBI limits, before it is saved. */
@@ -84,7 +84,11 @@ public class InvestmentInterestPostingWritePlatformServiceImpl implements Invest
}
final List<InvestmentInterestPosting> posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(purchaseId);
final InvestmentInterestCalculator.ScheduleRow next = this.readPlatformService.scheduleRows(p).stream()
final List<InvestmentInterestCalculator.ScheduleRow> schedule = this.readPlatformService.scheduleRows(p);
if (schedule.isEmpty()) {
throw rule("no.interest.lines", "Deal " + p.getDealNo() + " (zero-coupon or mutual fund) has no interest lines to post");
}
final InvestmentInterestCalculator.ScheduleRow next = schedule.stream()
.filter(r -> posted.stream().noneMatch(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())))
.findFirst().orElseThrow(() -> rule("all.posted", "All interest lines of deal " + p.getDealNo() + " are already posted"));
if (!next.type().equals(lineType) || !next.date().equals(intDate)) {
@@ -24,6 +24,7 @@ import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
@@ -61,6 +62,7 @@ public class InvestmentInterestReportService {
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final InvestmentFdRepository fdRepository;
private final InvestmentFdInterestRepository fdInterestRepository;
private final InvestmentFdInterestService fdInterestService;
@Transactional(readOnly = true)
public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) {
@@ -119,7 +121,7 @@ public class InvestmentInterestReportService {
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)) {
if (heldOnTo && !p.getSettlementDate().isAfter(to) && !InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType())) {
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
LocalDate lastCoupon = p.getLastIntDate();
for (final InvestmentInterestCalculator.ScheduleRow r : this.purchaseReadPlatformService.scheduleRows(p)) {
@@ -140,7 +142,7 @@ public class InvestmentInterestReportService {
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);
p.getFaceValue(), p.getIntRate(), received, receivable, provisioned);
}
private void fd(final InvestmentFd f, final LocalDate from, final LocalDate to, final List<InvestmentInterestReportData.Row> rows) {
@@ -170,7 +172,18 @@ public class InvestmentInterestReportService {
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());
// Payouts already due but not posted count at the schedule's own (paisa-rounded) amounts; interest runs on
// from the last of them, so the receivable ties to the FD Interest Entry to the paisa.
LocalDate periodStart = lastPaid;
for (final InvestmentFdInterestService.Line l : this.fdInterestService.lines(f)) {
if (InvestmentFdApiConstants.LINE_PAYOUT.equals(l.type()) && l.posted() == null && l.date().isAfter(lastPaid)
&& !l.date().isAfter(end)) {
receivable = receivable.add(l.gross());
periodStart = l.date();
}
}
receivable = receivable.add(InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), periodStart, end, f.getCalcType(),
f.getPayout()));
} else {
provisioned = BigDecimal.ZERO;
}
@@ -70,6 +70,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
@@ -106,6 +107,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
requirePartyRules(party);
final InvestmentParty saved = saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getPartyCode()).build();
@@ -116,10 +118,14 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
this.validator.validateForUpdateParty(command.json());
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
final String oldBondType = party.getBondType();
final Long oldGlId = party.getGlAccount() == null ? null : party.getGlAccount().getId();
applyParty(party, command);
if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
}
requireFdTermsUnchangedWhileActive(party, oldBondType, oldGlId);
requirePartyRules(party);
saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId)
.withResourceIdAsString(party.getPartyCode()).build();
@@ -129,11 +135,64 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
@Transactional
public CommandProcessingResult deleteParty(final Long partyId) {
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
// A party with deals or FDs (dropped ones too, which keep their numbers) stays: make it inactive instead.
final Integer used = this.jdbcTemplate.queryForObject("SELECT (SELECT COUNT(*) FROM m_investment_purchase WHERE party_id = ?)"
+ " + (SELECT COUNT(*) FROM m_investment_fd WHERE party_id = ?)", Integer.class, partyId, partyId);
if (used != null && used > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.in.use", "Party " + party.getPartyCode() + " has "
+ used + " deal(s) / FD(s) and cannot be deleted; make it inactive instead", party.getPartyCode());
}
this.partyRepository.delete(party);
this.partyRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(partyId).withResourceIdAsString(party.getPartyCode()).build();
}
/**
* An FD bank's FD GL holds the money of its running FDs and every closure / renewal books against the party's GL, so
* neither the GL nor the bond type may change while the bank has an active FD.
*/
private void requireFdTermsUnchangedWhileActive(final InvestmentParty party, final String oldBondType, final Long oldGlId) {
final Long newGlId = party.getGlAccount() == null ? null : party.getGlAccount().getId();
if (Objects.equals(oldBondType, party.getBondType()) && Objects.equals(oldGlId, newGlId)) {
return;
}
final Integer activeFds = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_investment_fd WHERE party_id = ? AND status = 'ACTIVE'", Integer.class, party.getId());
if (activeFds != null && activeFds > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.fd.active", "Party " + party.getPartyCode()
+ " has " + activeFds + " active FD(s), so its bond type and GL cannot be changed", party.getPartyCode());
}
}
/**
* One party per name and bond type; an FD bank sits on an FD GL (under 100704), has an interest GL, and a TDS rate
* below 100%.
*/
private void requirePartyRules(final InvestmentParty party) {
final Integer same = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_investment_party WHERE LOWER(TRIM(party_name)) = LOWER(TRIM(?)) AND bond_type = ? AND id <> ?",
Integer.class, party.getPartyName(), party.getBondType(), party.getId() == null ? -1L : party.getId());
if (same != null && same > 0) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.name.duplicate",
"A " + party.getBondType() + " party named " + party.getPartyName() + " already exists", party.getPartyName());
}
if (!"FD".equals(party.getBondType())) {
return;
}
final String glCode = party.getGlAccount() == null ? "" : party.getGlAccount().getGlCode();
if (!glCode.startsWith("100704") || "100704".equals(glCode)) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.fd.gl",
"An FD bank needs its own FD GL under 100704 (Fixed Deposits with banks), not " + glCode, glCode);
}
if (party.getInterestGlAccount() == null) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.fd.interest.gl",
"Map the interest income GL of FD bank " + party.getPartyName());
}
if (party.isTdsApplicable() && party.getTdsRate() != null && party.getTdsRate().compareTo(BigDecimal.valueOf(100)) >= 0) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.tds.rate", "TDS rate must be below 100%");
}
}
/** Copies every party field present in the request onto the entity (absent fields are left unchanged). */
private void applyParty(final InvestmentParty p, final JsonCommand command) {
setIfPresent(command, BOND_TYPE, p::setBondType);
@@ -308,13 +367,31 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
if (command.parameterExists(MATURITY_DATE)) {
s.setMaturityDate(command.localDateValueOfParameterNamed(MATURITY_DATE));
}
if (s.getIssueDate() != null && s.getMaturityDate() != null && s.getMaturityDate().isBefore(s.getIssueDate())) {
if (s.getIssueDate() != null && s.getMaturityDate() != null && !s.getMaturityDate().isAfter(s.getIssueDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.maturity.before.issue",
"Maturity date cannot be before the issue date");
"Maturity date must be after the issue date");
}
if (command.parameterExists(GL_ACCOUNT_ID)) {
s.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID)));
}
// Only a Govt Security can count towards SLR; a bond / CP / MF is non-SLR whatever is typed.
if (!"GOVT_SECURITY".equals(s.getBondType()) && s.getSlrType() != null && !"NON_SLR".equals(s.getSlrType())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.slrtype.not.govt",
"Only a Govt Security can be classed " + s.getSlrType() + "; a " + s.getBondType() + " script is NON_SLR");
}
// RBI: a rating counts only when a credit rating agency gave it.
if (InvestmentApiConstants.RATED_BOND_TYPES.contains(s.getBondType()) && StringUtils.isNotBlank(s.getRating())
&& !"UNRATED".equals(s.getRating()) && StringUtils.isBlank(s.getRatingAgency())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.rating.agency.missing",
"Enter the rating agency that gave the " + s.getRating() + " rating");
}
// A CP / CD (up to one year) takes a short-term rating, a bond a long-term one.
if (StringUtils.isNotBlank(s.getRating()) && !"UNRATED".equals(s.getRating())
&& InvestmentNonSlrService.shortTerm(s) != InvestmentApiConstants.SHORT_TERM_RATINGS.contains(s.getRating())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.rating.scale",
InvestmentNonSlrService.shortTerm(s) ? "A CP / CD (up to one year) takes a short-term rating (A1+ to A4)"
: "A bond (over one year) takes a long-term rating (AAA to D)");
}
}
/** An ISIN identifies one security, so two scripts cannot carry it; checked when it is entered or changed. */
@@ -322,6 +399,11 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
if (StringUtils.isBlank(isin) || isin.trim().equalsIgnoreCase(StringUtils.trimToEmpty(s.getIsin()))) {
return;
}
// A new or changed ISIN must look like one: 2-letter country, 9 alphanumerics, a check digit (IN0020230085).
if (!isin.trim().toUpperCase().matches("[A-Z]{2}[A-Z0-9]{9}[0-9]")) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.isin.format",
"ISIN " + isin.trim() + " is not valid: 12 characters, 2 letters, 9 letters / digits and a final digit", isin.trim());
}
this.scriptRepository.findByIsinIgnoreCase(isin.trim()).stream().filter(other -> !other.getId().equals(s.getId())).findFirst()
.ifPresent(other -> {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.isin.duplicate",
@@ -22,6 +22,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ELIGIBLE_RATINGS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.NON_SLR_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATED_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SHORT_TERM_RATINGS;
import java.math.BigDecimal;
import java.math.RoundingMode;
@@ -72,9 +73,22 @@ public class InvestmentNonSlrService {
+ "RBI allows bonds rated A or higher and CP / CD rated A2 or higher");
}
if (!ELIGIBLE_RATINGS.contains(script.getRating())) {
throw rule("rating.below.minimum", "Script " + script.getScriptCode() + " is rated " + script.getRating()
throw rule("rating.below.minimum", "Script " + script.getScriptCode()
+ ("UNRATED".equals(script.getRating()) ? " is unrated" : " is rated " + script.getRating())
+ "; RBI allows bonds rated A or higher and CP / CD rated A2 or higher");
}
if (StringUtils.isBlank(script.getRatingAgency())) {
throw rule("rating.agency.missing", "Script " + script.getScriptCode() + " has no rating agency; RBI counts a rating only "
+ "when a SEBI-registered agency gave it - enter it in Script Master first");
}
// A CP / CD (up to one year) is rated on the short-term scale, a bond on the long-term one: a bond cannot pass the
// minimum on a short-term grade (A2) nor a CP on a long-term one.
final boolean shortTerm = shortTerm(script);
if (shortTerm != SHORT_TERM_RATINGS.contains(script.getRating())) {
throw rule("rating.scale", "Script " + script.getScriptCode() + " is rated " + script.getRating() + " but is a "
+ (shortTerm ? "CP / CD (up to one year): use a short-term rating (A1+ to A4)"
: "bond (over one year): use a long-term rating (AAA to D)"));
}
}
if (MUTUAL_FUND.equals(script.getBondType()) && !StringUtils.startsWithIgnoreCase(subType, DEBT_MF_SUB_TYPE_PREFIX)) {
throw rule("mf.not.debt", "RBI allows only debt and money-market mutual funds; choose a DEBT sub type");
@@ -143,6 +157,11 @@ public class InvestmentNonSlrService {
return b.withinLimits(true).message("Within the RBI limits").build();
}
/** A CP / CD: issued for one year or less (short-term rating scale); anything longer is a bond. */
static boolean shortTerm(final InvestmentScript s) {
return s.getIssueDate() != null && s.getMaturityDate() != null && !s.getMaturityDate().isAfter(s.getIssueDate().plusYears(1));
}
/** Only bonds, CPs and CDs can be unlisted for the RBI limit; mutual fund units are not reckoned there. */
private static boolean unlisted(final InvestmentScript s) {
return RATED_BOND_TYPES.contains(s.getBondType()) && !s.isListed();
@@ -190,8 +209,23 @@ public class InvestmentNonSlrService {
return total.multiply(LIMIT_PERCENT).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private static String plain(final BigDecimal v) {
return v.setScale(2, RoundingMode.HALF_UP).toPlainString();
/** Rupees with Indian grouping, as the officer reads them: 10,00,00,000.00. */
static String plain(final BigDecimal v) {
final String digits = v.setScale(2, RoundingMode.HALF_UP).abs().toPlainString();
final String whole = digits.substring(0, digits.indexOf('.'));
final StringBuilder out = new StringBuilder();
final int firstGroup = whole.length() > 3 ? whole.length() - 3 : 0;
for (int i = 0; i < firstGroup; i++) {
out.append(whole.charAt(i));
if ((firstGroup - i - 1) % 2 == 0 && i < firstGroup - 1) {
out.append(',');
}
}
if (firstGroup > 0) {
out.append(',');
}
out.append(whole.substring(firstGroup)).append(digits.substring(digits.indexOf('.')));
return (v.signum() < 0 ? "-" : "") + out;
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.investment.service;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
@@ -28,8 +29,10 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
@@ -38,6 +41,8 @@ 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.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
@@ -53,6 +58,7 @@ public class InvestmentPurchaseReadPlatformService {
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentInterestPostingRepository postingRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final InvestmentSaleRepository saleRepository;
/** All deals, newest first — the Holdings register. Dropped deals only hold their numbers and are left out. */
public List<InvestmentPurchaseData> retrieveAll() {
@@ -65,6 +71,17 @@ public class InvestmentPurchaseReadPlatformService {
return InvestmentPurchaseData.from(find(purchaseId));
}
/** Book value still held: cost less what settled sales, premium amortisation and write-offs took off. */
private BigDecimal bookHeld(final InvestmentPurchase p) {
BigDecimal book = p.getBookValue();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus()) && s.getBookValueSold() != null) {
book = book.subtract(s.getBookValueSold());
}
}
return book;
}
/**
* Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still
* held, with what has been posted. Lines on or before {@code asOnDate} (default: business date) are marked due; the
@@ -106,7 +123,7 @@ public class InvestmentPurchaseReadPlatformService {
return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
.unitValue(p.getScript().getFaceValuePerUnit()).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate())
.bookValue(p.getBookValue()).intRate(p.getIntRate()).dealDate(format(p.getDealDate()))
.bookValue(bookHeld(p)).intRate(p.getIntRate()).dealDate(format(p.getDealDate()))
.lastIntDate(format(p.getLastIntDate())).maturityDate(format(p.getMaturityDate()))
.purchasedAccruedInterest(p.getAccruedInterest())
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO)
@@ -117,6 +134,10 @@ public class InvestmentPurchaseReadPlatformService {
/** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */
public List<InvestmentInterestCalculator.ScheduleRow> scheduleRows(final InvestmentPurchase p) {
// Non-SLR (bonds, mutual funds) has no interest scheme (Jitendra Sir): its income is the gain on sale / redemption.
if (InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType())) {
return List.of();
}
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
return InvestmentInterestCalculator.schedule(faceHeld(p), rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate());
}
@@ -167,7 +188,12 @@ public class InvestmentPurchaseReadPlatformService {
return posted.stream().filter(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())).findFirst();
}
/** Face value of the units held; a fund or share has no face value, so its cost held stands in for it. */
private static BigDecimal faceHeld(final InvestmentPurchase p) {
if ("MUTUAL_FUND".equals(p.getBondType()) || "SHARE".equals(p.getBondType())) {
return p.getUnits().signum() == 0 ? BigDecimal.ZERO
: p.getFaceValue().multiply(p.getBalanceUnits()).divide(p.getUnits(), 2, RoundingMode.HALF_UP);
}
return p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit());
}
@@ -122,16 +122,36 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
if (!party.isActive()) {
throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive");
}
// An FD bank is where the bank places deposits, not a dealer it buys securities from.
if ("FD".equals(party.getBondType())) {
throw rule("party.fd", "Party " + party.getPartyCode() + " " + party.getPartyName()
+ " is an FD bank; choose a dealer / counterparty party for a " + bondType + " purchase");
}
final boolean nonSlr = NON_SLR_BOND_TYPES.contains(bondType);
if (nonSlr) {
this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE));
// Jitendra Sir: non-SLR is bought and sold at any time for the price difference, so it is always AFS.
if (!StringUtils.startsWith(command.stringValueOfParameterNamed(CATEGORY), "AFS")) {
throw rule("nonslr.category", "A non-SLR investment (bond / mutual fund) is always AFS (Available For Sale)");
}
}
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) {
throw rule("deal.after.maturity", "Deal date is after the security's maturity date " + script.getMaturityDate());
if (dealDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("deal.future", "The purchase deal date cannot be after today");
}
final String settlementType = command.stringValueOfParameterNamed(SETTLEMENT_TYPE);
// The security must still be running when it reaches us: settlement on or after maturity buys nothing.
final LocalDate settlementDate = dealDate.plusDays(Long.parseLong(settlementType.substring(2)));
if (script.getIssueDate() != null && settlementDate.isBefore(script.getIssueDate())) {
throw rule("buy.before.issue", "Settlement date " + settlementDate.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"))
+ " is before the security's issue date " + script.getIssueDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
if (script.getMaturityDate() != null && !settlementDate.isBefore(script.getMaturityDate())) {
final DateTimeFormatter ddMmYyyy = DateTimeFormatter.ofPattern("dd-MM-yyyy");
throw rule("buy.at.maturity", "Settlement date " + settlementDate.format(ddMmYyyy)
+ " must be before the security's maturity date " + script.getMaturityDate().format(ddMmYyyy));
}
final InvestmentPurchase p = new InvestmentPurchase();
p.setDealNo(nextDealNo(dealDate));
@@ -142,7 +162,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setDealDate(dealDate);
p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME)));
p.setSettlementType(settlementType);
p.setSettlementDate(dealDate.plusDays(Long.parseLong(settlementType.substring(2))));
p.setSettlementDate(settlementDate);
p.setCategory(command.stringValueOfParameterNamed(CATEGORY));
p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)));
p.setIntRate(script.getIntRate());
@@ -160,6 +180,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setOtherCharges(zeroIfNull(command.bigDecimalValueOfParameterNamed(OTHER_CHARGES)));
if ("MUTUAL_FUND".equals(bondType)) {
computeMutualFund(p, command);
} else if (nonSlr) {
computeNonSlrBond(p, command, script);
} else {
computeSecurity(p, command, script);
}
@@ -167,7 +189,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
if (nonSlr) {
final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue());
if (!limit.isWithinLimits()) {
throw rule("nonslr.limit.exceeded", limit.getMessage());
throw rule(limit.getTotalDeposits() == null ? "nonslr.deposit.base.missing" : "nonslr.limit.exceeded", limit.getMessage());
}
}
@@ -181,6 +203,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
public CommandProcessingResult deletePurchase(final Long purchaseId) {
final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (STATUS_DROPPED.equals(p.getStatus())) {
throw rule("purchase.dropped", "Deal " + p.getDealNo() + " is already dropped");
}
if (!STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
throw rule("purchase.settled", "Deal " + p.getDealNo() + " is already settled and posted to GL, so it cannot be dropped");
}
@@ -246,8 +271,27 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
if (lastIntDate.isAfter(p.getDealDate())) {
throw rule("lastint.after.deal", "Last interest date cannot be after the deal date");
// Broken-period interest and the coupon schedule both run from the last coupon on or before SETTLEMENT (a coupon
// falling between deal and settlement is paid to the seller), or from the issue date before the first coupon.
if (lastIntDate.isAfter(p.getSettlementDate())) {
throw rule("lastint.after.settlement", "Last interest date cannot be after the settlement date "
+ p.getSettlementDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
if (zeroIfNull(script.getIntRate()).signum() > 0 && script.getMaturityDate() != null) {
LocalDate expected = InvestmentInterestCalculator.lastCouponOnOrBefore(script.getMaturityDate(), p.getSettlementDate());
if (script.getIssueDate() != null && expected.isBefore(script.getIssueDate())) {
expected = script.getIssueDate();
}
if (!lastIntDate.equals(expected)) {
throw rule("lastint.not.coupon", "Last interest date must be " + expected.format(DateTimeFormatter.ofPattern("dd-MM-yyyy"))
+ ", the last coupon of " + script.getScriptCode() + " on or before the settlement date "
+ p.getSettlementDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
}
// Securities are held in whole units (₹100 each for G-Secs): a face value that is not a multiple is a typing slip.
if (faceValue.remainder(script.getFaceValuePerUnit()).signum() != 0) {
throw rule("facevalue.not.multiple", "Face value must be a multiple of the unit value "
+ script.getFaceValuePerUnit().stripTrailingZeros().toPlainString() + " of " + script.getScriptCode());
}
final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP);
@@ -265,7 +309,28 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setLastIntDate(lastIntDate);
p.setAccruedDays(days);
p.setAccruedInterest(accrued);
p.setYtm(approxYtm(intRate, rate, script.getIssueDate(), script.getMaturityDate()));
p.setYtm(approxYtm(intRate, rate, p.getSettlementDate(), script.getMaturityDate()));
}
/**
* Non-SLR bond (Jitendra Sir): no interest scheme — no broken-period interest, no premium split, no coupons. The
* price paid is the book value; the gain or loss is taken when it is sold or redeemed.
*/
private void computeNonSlrBond(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script) {
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
if (faceValue.remainder(script.getFaceValuePerUnit()).signum() != 0) {
throw rule("facevalue.not.multiple", "Face value must be a multiple of the unit value "
+ script.getFaceValuePerUnit().stripTrailingZeros().toPlainString() + " of " + script.getScriptCode());
}
p.setFaceValue(faceValue);
p.setPurchaseRate(rate);
p.setUnits(faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP));
p.setBalanceUnits(p.getUnits());
p.setBookValue(faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP));
p.setPremiumDiscount(BigDecimal.ZERO);
p.setAccruedDays(0);
p.setAccruedInterest(BigDecimal.ZERO);
}
/** Mutual fund: the amount invested buys units at the NAV. */
@@ -287,12 +352,12 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return p.getBrokerage().add(p.getStampDuty()).add(p.getOtherCharges());
}
/** Approximate yield: coupon adjusted for the discount/premium spread over the security's life, over the price. */
static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate issue, final LocalDate maturity) {
if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) {
/** Approximate yield: coupon adjusted for the discount/premium spread over the remaining life (from to maturity), over the price. */
static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate from, final LocalDate maturity) {
if (coupon.signum() == 0 || from == null || maturity == null || !maturity.isAfter(from)) {
return coupon;
}
final double years = ChronoUnit.DAYS.between(issue, maturity) / 365.25;
final double years = ChronoUnit.DAYS.between(from, maturity) / 365.25;
final double ytm = (coupon.doubleValue() + (100 - price.doubleValue()) / years) / price.doubleValue() * 100;
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP);
}
@@ -84,6 +84,10 @@ public class InvestmentSaleQuoter {
if (r.party() != null && !r.party().isActive()) {
throw rule("party.inactive", "Party " + r.party().getPartyCode() + " is inactive");
}
if (r.party() != null && "FD".equals(r.party().getBondType())) {
throw rule("party.fd", "Party " + r.party().getPartyCode() + " " + r.party().getPartyName()
+ " is an FD bank; choose a dealer / counterparty party as the buyer");
}
if (r.dealDate().isBefore(p.getSettlementDate())) {
throw rule("deal.before.purchase", "A sale cannot be dated before the purchase settled on " + p.getSettlementDate().format(DATE));
}
@@ -91,7 +95,7 @@ public class InvestmentSaleQuoter {
throw rule("deal.future", "The sale deal date cannot be after today");
}
final LocalDate settlementDate = r.dealDate().plusDays(Long.parseLong(r.settlementType().substring(2)));
if (!settlementDate.isBefore(p.getMaturityDate())) {
if (p.getMaturityDate() != null && !settlementDate.isBefore(p.getMaturityDate())) {
throw rule("sell.at.maturity", "The security matures on " + p.getMaturityDate().format(DATE)
+ "; on or after maturity it is redeemed, not sold");
}
@@ -106,14 +110,31 @@ public class InvestmentSaleQuoter {
+ " are available to sell");
}
final boolean mutualFund = "MUTUAL_FUND".equals(p.getBondType());
// Non-SLR (bond, mutual fund) has no interest scheme (Jitendra Sir): the whole gain is the price difference.
final boolean nonSlr = InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType());
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
final BigDecimal face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP);
final BigDecimal consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final LocalDate lastCoupon = lastCoupon(p, settlementDate);
final int days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(face, rate, days);
final BigDecimal receivable = receivableShare(p, lastCoupon, settlementDate, r.units()).min(accrued);
final BigDecimal face;
final BigDecimal consideration;
if (mutualFund) {
// A fund is sold / redeemed at its NAV per unit; its "face" is the cost of the units sold.
consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP);
face = p.getUnits().signum() == 0 ? BigDecimal.ZERO
: p.getFaceValue().multiply(r.units()).divide(p.getUnits(), 2, RoundingMode.HALF_UP);
} else {
face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP);
consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP);
}
int days = 0;
BigDecimal accrued = BigDecimal.ZERO;
BigDecimal receivable = BigDecimal.ZERO;
if (!nonSlr) {
final LocalDate lastCoupon = lastCoupon(p, settlementDate);
days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
accrued = InvestmentInterestCalculator.interestForDays(face, rate, days);
receivable = receivableShare(p, lastCoupon, settlementDate, r.units()).min(accrued);
}
final boolean allRemaining = r.units().compareTo(p.getBalanceUnits()) == 0;
// Cost is the units' share of the book value as it stands now (after earlier sales and write-offs).
final BigDecimal book = remainingBook(p);
@@ -152,6 +173,10 @@ public class InvestmentSaleQuoter {
/** Repayment at maturity of all units still held, dated the maturity date. */
public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) {
requireActiveDebtHolding(p);
if (p.getMaturityDate() == null) {
throw rule("redeem.no.maturity", "Deal " + p.getDealNo() + " has no maturity date; redeem a mutual fund at its NAV through the "
+ "Sell / Redeem entry");
}
if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE)
+ "; it can be redeemed on or after that date");
@@ -274,8 +299,8 @@ public class InvestmentSaleQuoter {
if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding");
}
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) {
throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Non-SLR and Bond holdings");
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType()) && !"MUTUAL_FUND".equals(p.getBondType())) {
throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Bond and Mutual Fund holdings");
}
}
@@ -49,6 +49,8 @@ import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@@ -120,7 +122,7 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
.debit(this.journalPoster.glId(BROKERAGE_GL_CODE), s.getBrokerage())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), s.getPremiumCleared());
profitOrLoss(lines, s.getProfitLoss(), LOSS_ON_SALE_GL_CODE);
profitOrLoss(lines, s.getProfitLoss(), profitGlCode(p), LOSS_ON_SALE_GL_CODE);
final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment sale settled: deal " + p.getDealNo() + ", " + s.getUnits().stripTrailingZeros().toPlainString() + " units of "
+ p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc() + " to " + s.getParty().getPartyName(),
@@ -167,7 +169,7 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines().debit(s.getBankGlAccount().getId(), s.getNetAmount())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), s.getPremiumCleared());
profitOrLoss(lines, s.getProfitLoss(), PREMIUM_AMORTISATION_GL_CODE);
profitOrLoss(lines, s.getProfitLoss(), profitGlCode(p), nonSlr(p) ? LOSS_ON_SALE_GL_CODE : PREMIUM_AMORTISATION_GL_CODE);
final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment redeemed at maturity: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " "
+ p.getScript().getScriptDesc(),
@@ -243,14 +245,24 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
}
/** Profit is credited to income; a shortfall is debited to {@code lossGlCode}. */
private void profitOrLoss(final InvestmentJournalPoster.Lines lines, final BigDecimal profitLoss, final String lossGlCode) {
private void profitOrLoss(final InvestmentJournalPoster.Lines lines, final BigDecimal profitLoss, final String profitGlCode,
final String lossGlCode) {
if (profitLoss.signum() > 0) {
lines.credit(this.journalPoster.glId(PROFIT_ON_SALE_GL_CODE), profitLoss);
lines.credit(this.journalPoster.glId(profitGlCode), profitLoss);
} else if (profitLoss.signum() < 0) {
lines.debit(this.journalPoster.glId(lossGlCode), profitLoss.negate());
}
}
private static boolean nonSlr(final InvestmentPurchase p) {
return InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType());
}
/** Non-SLR earns only on sale / redemption, so its gain is its interest income (400402, Jitendra Sir); else 400405. */
private static String profitGlCode(final InvestmentPurchase p) {
return nonSlr(p) ? InvestmentInterestApiConstants.INTEREST_INCOME_BONDS_GL_CODE : PROFIT_ON_SALE_GL_CODE;
}
private InvestmentSaleQuoter.SellRequest sellRequest(final JsonCommand command) {
final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
@@ -41,7 +41,6 @@ import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentSecurityTransferData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
@@ -90,8 +89,12 @@ public class InvestmentSecurityTransferService {
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript to = this.scriptRepository.findById(scriptId).orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final InvestmentScript from = p.getScript();
if (!STATUS_ACTIVE.equals(p.getStatus()) || !InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active Govt Security / Bond holding");
if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active holding");
}
// A category transfer is for Govt Securities; moving a bond would skip the non-SLR rating and unlisted-limit checks.
if (!"GOVT_SECURITY".equals(p.getBondType())) {
throw rule("not.govt", "Only Govt Security holdings can be transferred; deal " + p.getDealNo() + " is a " + p.getBondType());
}
if (this.quoter.availableUnits(p, null).compareTo(p.getBalanceUnits()) != 0) {
throw rule("pending.sale", "Deal " + p.getDealNo() + " has a pending sale; settle or drop it first");