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.Produces;
import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper; 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") @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) { public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY); 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 @POST
@@ -61,6 +61,9 @@ public final class InvestmentApiConstants {
/** RBI: corporate bonds rated "A" or higher, CPs and CDs rated "A2" or higher. */ /** 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"); 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. */ /** Credit rating agencies registered with SEBI. */
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK", public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
"INFOMERICS"); "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.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -43,7 +44,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class InvestmentBucketReportApiResource { 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 PlatformSecurityContext context;
private final InvestmentBucketReportService reportService; private final InvestmentBucketReportService reportService;
@@ -56,11 +57,16 @@ public class InvestmentBucketReportApiResource {
if (asOnDate == null || asOnDate.isBlank()) { if (asOnDate == null || asOnDate.isBlank()) {
return this.reportService.report(DateUtils.getBusinessLocalDate()); return this.reportService.report(DateUtils.getBusinessLocalDate());
} }
final LocalDate asOn;
try { try {
return this.reportService.report(LocalDate.parse(asOnDate.trim(), DATE)); asOn = LocalDate.parse(asOnDate.trim(), DATE);
} catch (final DateTimeParseException e) { } catch (final DateTimeParseException e) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY", throw new GeneralPlatformDomainRuleException("error.msg.investment.report.date.invalid", "Enter the date as DD-MM-YYYY",
asOnDate); 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.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; 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, public InvestmentDailyRateData retrieveLatest(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@QueryParam("onDate") @Parameter(description = "dd-MM-yyyy") final String onDate) { @QueryParam("onDate") @Parameter(description = "dd-MM-yyyy") final String onDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY); this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
final LocalDate on = StringUtils.isBlank(onDate) ? DateUtils.getBusinessLocalDate() final LocalDate on = InvestmentApiDates.parse(onDate, DateUtils.getBusinessLocalDate(), "date");
: LocalDate.parse(onDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveLatest(scriptId, on); return this.readPlatformService.retrieveLatest(scriptId, on);
} }
@@ -30,6 +30,7 @@ import java.time.LocalDate;
import java.time.Month; import java.time.Month;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.time.format.ResolverStyle;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -44,7 +45,7 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class InvestmentInterestReportApiResource { 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 PlatformSecurityContext context;
private final InvestmentInterestReportService reportService; private final InvestmentInterestReportService reportService;
@@ -57,9 +58,13 @@ public class InvestmentInterestReportApiResource {
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) { @QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY); this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate today = DateUtils.getBusinessLocalDate(); 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); 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)) { if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.report.dates", "The To date cannot be before the From date"); 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 jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -80,8 +79,7 @@ public class InvestmentPurchaseApiResource {
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate, @QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) { @QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY); this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate date = StringUtils.isBlank(dealDate) ? DateUtils.getBusinessLocalDate() final LocalDate date = InvestmentApiDates.parse(dealDate, DateUtils.getBusinessLocalDate(), "deal date");
: LocalDate.parse(dealDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.nonSlrService.limits(date, scriptId, amount); return this.nonSlrService.limits(date, scriptId, amount);
} }
@@ -103,8 +101,7 @@ public class InvestmentPurchaseApiResource {
@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId, @PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) { @QueryParam("asOnDate") @Parameter(description = "dd-MM-yyyy") final String asOnDate) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY); this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null final LocalDate asOn = StringUtils.isBlank(asOnDate) ? null : InvestmentApiDates.parse(asOnDate, null, "as-on date");
: LocalDate.parse(asOnDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn); return this.readPlatformService.retrieveInterestSchedule(purchaseId, asOn);
} }
@@ -32,7 +32,6 @@ import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.MediaType;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
@@ -51,8 +50,6 @@ import org.springframework.stereotype.Component;
@RequiredArgsConstructor @RequiredArgsConstructor
public class InvestmentSaleApiResource { public class InvestmentSaleApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context; private final PlatformSecurityContext context;
private final InvestmentSaleReadPlatformService readPlatformService; private final InvestmentSaleReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@@ -78,7 +75,7 @@ public class InvestmentSaleApiResource {
@QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage, @QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage,
@QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) { @QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY); 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, return this.readPlatformService.quoteSale(purchaseId, deal, StringUtils.defaultIfBlank(settlementType, "T+0"), units, saleRate,
brokerage, bankGlAccountId); brokerage, bankGlAccountId);
} }
@@ -34,7 +34,10 @@ public class InvestmentFdDueData {
/** PROVISION, PAYOUT or MATURITY. */ /** PROVISION, PAYOUT or MATURITY. */
private final String lineType; private final String lineType;
private final String intDate; private final String intDate;
/** What the bank credits (net of TDS); a provision line: what it books. */
private final BigDecimal amount; private final BigDecimal amount;
private final BigDecimal grossInterest;
private final BigDecimal tds;
private final boolean postable; private final boolean postable;
private final boolean overdue; private final boolean overdue;
} }
@@ -50,7 +50,7 @@ public class InvestmentInterestReportData {
/** Script, or bank and certificate of an FD. */ /** Script, or bank and certificate of an FD. */
private final String particulars; private final String particulars;
private final String status; 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 principal;
private final BigDecimal rate; private final BigDecimal rate;
/** Interest that came in during the period (coupons, FD payouts, interest paid at closure), gross of TDS. */ /** 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; package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository; 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 String bondType = create ? this.fromApiJsonHelper.extractStringNamed(BOND_TYPE, element) : existingBondType;
final boolean debt = bondType != null && DEBT_BOND_TYPES.contains(bondType); 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); 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).notBlank();
} }
v.reset().parameter(ISIN).value(isin).ignoreIfNull().notExceedingLengthOf(20); 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)) { if (this.fromApiJsonHelper.parameterExists(INT_RATE, element) || (create && debt)) {
final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element); final BigDecimal intRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, element);
final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate); final DataValidatorBuilder rate = v.reset().parameter(INT_RATE).value(intRate);
if (create && debt) { if (debt) {
rate.notNull(); rate.notNull();
} }
rate.ignoreIfNull().zeroOrPositiveAmount(); rate.ignoreIfNull().zeroOrPositiveAmount();
@@ -190,7 +191,7 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
LocalDate issueDate = null; LocalDate issueDate = null;
if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) { if (this.fromApiJsonHelper.parameterExists(ISSUE_DATE, element) || (create && debt)) {
issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element); issueDate = this.fromApiJsonHelper.extractLocalDateNamed(ISSUE_DATE, element);
if (create && debt) { if (debt) {
v.reset().parameter(ISSUE_DATE).value(issueDate).notNull(); 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, COMPANY_NAME, 150);
validateOptionalString(element, v, CORP_CODE, 30); 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); validateAddress(element, v);
throwIfErrors(errors); throwIfErrors(errors);
} }
@@ -107,7 +107,10 @@ public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
} else { } else {
v.reset().parameter(FACE_VALUE).value(amount(FACE_VALUE, element)).notNull().positiveAmount(); 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(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(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, 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.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale; import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository; 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.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -58,6 +61,7 @@ public class InvestmentBucketReportService {
private final InvestmentPurchaseRepository purchaseRepository; private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository; private final InvestmentSaleRepository saleRepository;
private final InvestmentFdRepository fdRepository; private final InvestmentFdRepository fdRepository;
private final InvestmentSecurityTransferRepository transferRepository;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentBucketReportData report(final LocalDate asOn) { public InvestmentBucketReportData report(final LocalDate asOn) {
@@ -109,9 +113,13 @@ public class InvestmentBucketReportService {
if (units.signum() <= 0) { if (units.signum() <= 0) {
return; 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(), 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(), script.getScriptCode() + " " + script.getScriptDesc(), p.getCategory(), p.getIntRate(), p.getMaturityDate(),
units.multiply(p.getScript().getFaceValuePerUnit()), book)); units.multiply(script.getFaceValuePerUnit()), book));
} }
private static InvestmentBucketReportData.Row row(final LocalDate asOn, final String instrument, final Long refId, final String dealNo, 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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; 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.InvestmentDailyRate;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript; import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
@@ -58,6 +59,20 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa
final InvestmentScript script = this.scriptRepository.findById(scriptId) final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId)); .orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final LocalDate rateDate = command.localDateValueOfParameterNamed(RATE_DATE); 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)) { if (this.dailyRateRepository.existsByScriptIdAndRateDate(scriptId, rateDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.duplicate", throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.duplicate",
"A rate for " + script.getScriptCode() + " on this date already exists. Drop it first to correct it."); "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.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants; import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; 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.InvestmentAmortisationData;
import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData; import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData;
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData; 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.InvestmentFdRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; 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.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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 * Due Date Diary: the dated events of every investment between two dates, taken from the same schedules the entry
* entry screens use, so the diary always agrees with Interest Entry, Amortize Entry and the FD screens. * 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 @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -60,6 +64,7 @@ public class InvestmentDiaryService {
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService; private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final InvestmentAmortisationService amortisationService; private final InvestmentAmortisationService amortisationService;
private final InvestmentFdInterestService fdInterestService; private final InvestmentFdInterestService fdInterestService;
private final InvestmentSaleRepository saleRepository;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentDiaryData diary(final LocalDate from, final LocalDate to) { public InvestmentDiaryData diary(final LocalDate from, final LocalDate to) {
@@ -67,11 +72,29 @@ public class InvestmentDiaryService {
final List<InvestmentDiaryData.Row> rows = new ArrayList<>(); final List<InvestmentDiaryData.Row> rows = new ArrayList<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { 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; continue;
} }
final boolean open = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus());
final InvestmentInterestScheduleData s = this.purchaseReadPlatformService.retrieveInterestSchedule(p.getId(), null); final InvestmentInterestScheduleData s = this.purchaseReadPlatformService.retrieveInterestSchedule(p.getId(), null);
final String particulars = s.getScriptCode() + " " + s.getScriptDesc(); 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()) { for (final InvestmentInterestScheduleData.Row r : s.getRows()) {
// A coupon shows the cash coming in (the full coupon); a provision shows what it books. // 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(); 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 InvestmentFdScheduleData s = this.fdInterestService.schedule(f.getId());
final String particulars = f.getParty().getPartyName() + " · " + f.getCertNo(); 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()) { for (final InvestmentFdScheduleData.Row r : s.getRows()) {
final boolean maturity = InvestmentFdApiConstants.LINE_MATURITY.equals(r.getLineType()); 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. // Maturity: the principal and the last interest (net of TDS) the bank credits; a payout: the interest paid
final BigDecimal amount = maturity ? f.getExpectedMaturityAmount() // (net of TDS); a provision: what it books.
: InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType()) final BigDecimal tds = r.getTds() == null ? BigDecimal.ZERO : r.getTds();
? r.getGrossInterest().subtract(r.getTds() == null ? BigDecimal.ZERO : r.getTds()) final BigDecimal amount = maturity ? f.getPrincipal().add(r.getGrossInterest()).subtract(tds)
: r.getAmount(); : 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, add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars, amount,
r.isPosted(), r.getVoucherNo()); 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.InvestmentFdInterest;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentFdNotFoundException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; 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, 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 BigDecimal tds, final BigDecimal renewalAmount, final Long principalBankGlId, final Long interestBankGlId,
final Long diffBankGlId) { 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 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 LocalDate date = CLOSURE_PREMATURE.equals(t) ? (closeDate == null ? DateUtils.getBusinessLocalDate() : closeDate) : f.getMaturityDate();
final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount); final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount);
@@ -360,7 +364,7 @@ public class InvestmentFdClosureService {
private InvestmentFd active(final Long fdId) { private InvestmentFd active(final Long fdId) {
final InvestmentFd f = this.fdRepository.findByIdWithLock(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())) { if (!STATUS_ACTIVE.equals(f.getStatus())) {
throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase()); 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.InvestmentFdInterest;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository; 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.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -97,7 +98,7 @@ public class InvestmentFdInterestService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentFdScheduleData schedule(final Long fdId) { 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 List<Line> lines = lines(f);
final LocalDate today = DateUtils.getBusinessLocalDate(); final LocalDate today = DateUtils.getBusinessLocalDate();
final Optional<Line> next = lines.stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type())).findFirst(); 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()) .glTransactionId(l.posted() == null ? null : l.posted().getGlTransactionId())
.postable(STATUS_ACTIVE.equals(f.getStatus()) && next.isPresent() && next.get() == l && !l.date().isAfter(today)).build()); .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}. */ /** 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)) 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()) .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)) .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())); .postable(!l.date().isAfter(today)).overdue(l.date().isBefore(today)).build()));
} }
return due; 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) { 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() lines(f).stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type()) && l.date().isBefore(date)).findFirst()
.ifPresent(l -> { .ifPresent(l -> {
throw InvestmentFdService.rule("interest.pending", "Post the " + l.type().toLowerCase() + " of " 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) { public CommandProcessingResult post(final Long fdId, final JsonCommand command) {
validate(command.json()); validate(command.json());
final InvestmentFd f = this.fdRepository.findByIdWithLock(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())) { if (!STATUS_ACTIVE.equals(f.getStatus())) {
throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase()); throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase());
} }
@@ -274,7 +288,8 @@ public class InvestmentFdInterestService {
if (f.getClosedOnDate() == null) { if (f.getClosedOnDate() == null) {
final BigDecimal last = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, f.getMaturityDate(), final BigDecimal last = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, f.getMaturityDate(),
f.getCalcType(), f.getPayout()); 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; 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.InvestmentFdRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty; import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; 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.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@@ -115,7 +116,7 @@ public class InvestmentFdService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentFdData retrieveOne(final Long fdId) { 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. */ /** 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 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()))) .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")); .findFirst().orElseThrow(() -> rule("all.posted", "All interest lines of deal " + p.getDealNo() + " are already posted"));
if (!next.type().equals(lineType) || !next.date().equals(intDate)) { 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.ArrayList;
import java.util.List; import java.util.List;
import lombok.RequiredArgsConstructor; 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.InvestmentFdApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants; import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
@@ -61,6 +62,7 @@ public class InvestmentInterestReportService {
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService; private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final InvestmentFdRepository fdRepository; private final InvestmentFdRepository fdRepository;
private final InvestmentFdInterestRepository fdInterestRepository; private final InvestmentFdInterestRepository fdInterestRepository;
private final InvestmentFdInterestService fdInterestService;
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) { public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) {
@@ -119,7 +121,7 @@ public class InvestmentInterestReportService {
BigDecimal receivable = BigDecimal.ZERO; BigDecimal receivable = BigDecimal.ZERO;
final BigDecimal face = p.getBalanceUnits().add(soldAfterTo).multiply(p.getScript().getFaceValuePerUnit()); final BigDecimal face = p.getBalanceUnits().add(soldAfterTo).multiply(p.getScript().getFaceValuePerUnit());
final boolean heldOnTo = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus()) || soldAfterTo.signum() > 0; 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(); final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
LocalDate lastCoupon = p.getLastIntDate(); LocalDate lastCoupon = p.getLastIntDate();
for (final InvestmentInterestCalculator.ScheduleRow r : this.purchaseReadPlatformService.scheduleRows(p)) { for (final InvestmentInterestCalculator.ScheduleRow r : this.purchaseReadPlatformService.scheduleRows(p)) {
@@ -140,7 +142,7 @@ public class InvestmentInterestReportService {
provisioned = BigDecimal.ZERO; provisioned = BigDecimal.ZERO;
} }
add(rows, "SECURITY", p.getId(), p.getDealNo(), p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(), p.getStatus(), 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) { 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)); final boolean openOnTo = !f.getDealDate().isAfter(to) && (f.getClosedOnDate() == null || f.getClosedOnDate().isAfter(to));
if (openOnTo) { if (openOnTo) {
final LocalDate end = f.getMaturityDate().isBefore(to) ? f.getMaturityDate() : to; 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 { } else {
provisioned = BigDecimal.ZERO; 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.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformDataIntegrityException; 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.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
@@ -106,6 +107,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
if (command.parameterExists(ACTIVE)) { if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE)); party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
} }
requirePartyRules(party);
final InvestmentParty saved = saveParty(party); final InvestmentParty saved = saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(saved.getPartyCode()).build(); .withResourceIdAsString(saved.getPartyCode()).build();
@@ -116,10 +118,14 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) { public CommandProcessingResult updateParty(final Long partyId, final JsonCommand command) {
this.validator.validateForUpdateParty(command.json()); this.validator.validateForUpdateParty(command.json());
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId)); 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); applyParty(party, command);
if (command.parameterExists(ACTIVE)) { if (command.parameterExists(ACTIVE)) {
party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE)); party.setActive(command.booleanPrimitiveValueOfParameterNamed(ACTIVE));
} }
requireFdTermsUnchangedWhileActive(party, oldBondType, oldGlId);
requirePartyRules(party);
saveParty(party); saveParty(party);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId) return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(partyId)
.withResourceIdAsString(party.getPartyCode()).build(); .withResourceIdAsString(party.getPartyCode()).build();
@@ -129,11 +135,64 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
@Transactional @Transactional
public CommandProcessingResult deleteParty(final Long partyId) { public CommandProcessingResult deleteParty(final Long partyId) {
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(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.delete(party);
this.partyRepository.flush(); this.partyRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(partyId).withResourceIdAsString(party.getPartyCode()).build(); 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). */ /** 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) { private void applyParty(final InvestmentParty p, final JsonCommand command) {
setIfPresent(command, BOND_TYPE, p::setBondType); setIfPresent(command, BOND_TYPE, p::setBondType);
@@ -308,13 +367,31 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
if (command.parameterExists(MATURITY_DATE)) { if (command.parameterExists(MATURITY_DATE)) {
s.setMaturityDate(command.localDateValueOfParameterNamed(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", 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)) { if (command.parameterExists(GL_ACCOUNT_ID)) {
s.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(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. */ /** 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()))) { if (StringUtils.isBlank(isin) || isin.trim().equalsIgnoreCase(StringUtils.trimToEmpty(s.getIsin()))) {
return; 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() this.scriptRepository.findByIsinIgnoreCase(isin.trim()).stream().filter(other -> !other.getId().equals(s.getId())).findFirst()
.ifPresent(other -> { .ifPresent(other -> {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.isin.duplicate", 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.ELIGIBLE_RATINGS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.NON_SLR_BOND_TYPES; 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.RATED_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SHORT_TERM_RATINGS;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode; 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"); + "RBI allows bonds rated A or higher and CP / CD rated A2 or higher");
} }
if (!ELIGIBLE_RATINGS.contains(script.getRating())) { 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"); + "; 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)) { 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"); 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(); 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. */ /** 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) { private static boolean unlisted(final InvestmentScript s) {
return RATED_BOND_TYPES.contains(s.getBondType()) && !s.isListed(); 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); return total.multiply(LIMIT_PERCENT).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
} }
private static String plain(final BigDecimal v) { /** Rupees with Indian grouping, as the officer reads them: 10,00,00,000.00. */
return v.setScale(2, RoundingMode.HALF_UP).toPlainString(); 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) { private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.investment.service; package org.apache.fineract.portfolio.investment.service;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; 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.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.infrastructure.core.service.DateUtils; 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.InvestmentInterestApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; 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.InvestmentInterestDueData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData; import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; 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.InvestmentInterestPostingRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; 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.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -53,6 +58,7 @@ public class InvestmentPurchaseReadPlatformService {
private final InvestmentPurchaseRepository purchaseRepository; private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentInterestPostingRepository postingRepository; private final InvestmentInterestPostingRepository postingRepository;
private final GLAccountRepositoryWrapper glAccountRepository; 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. */ /** All deals, newest first — the Holdings register. Dropped deals only hold their numbers and are left out. */
public List<InvestmentPurchaseData> retrieveAll() { public List<InvestmentPurchaseData> retrieveAll() {
@@ -65,6 +71,17 @@ public class InvestmentPurchaseReadPlatformService {
return InvestmentPurchaseData.from(find(purchaseId)); 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 * 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 * 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()) return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld) .scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
.unitValue(p.getScript().getFaceValuePerUnit()).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()) .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())) .lastIntDate(format(p.getLastIntDate())).maturityDate(format(p.getMaturityDate()))
.purchasedAccruedInterest(p.getAccruedInterest()) .purchasedAccruedInterest(p.getAccruedInterest())
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO) .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. */ /** 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) { 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(); final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
return InvestmentInterestCalculator.schedule(faceHeld(p), rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate()); 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(); 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) { 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()); return p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit());
} }
@@ -122,16 +122,36 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
if (!party.isActive()) { if (!party.isActive()) {
throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive"); 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); final boolean nonSlr = NON_SLR_BOND_TYPES.contains(bondType);
if (nonSlr) { if (nonSlr) {
this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)); 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); final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) { if (dealDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("deal.after.maturity", "Deal date is after the security's maturity date " + script.getMaturityDate()); throw rule("deal.future", "The purchase deal date cannot be after today");
} }
final String settlementType = command.stringValueOfParameterNamed(SETTLEMENT_TYPE); 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(); final InvestmentPurchase p = new InvestmentPurchase();
p.setDealNo(nextDealNo(dealDate)); p.setDealNo(nextDealNo(dealDate));
@@ -142,7 +162,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setDealDate(dealDate); p.setDealDate(dealDate);
p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME))); p.setDealTime(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(DEAL_TIME)));
p.setSettlementType(settlementType); p.setSettlementType(settlementType);
p.setSettlementDate(dealDate.plusDays(Long.parseLong(settlementType.substring(2)))); p.setSettlementDate(settlementDate);
p.setCategory(command.stringValueOfParameterNamed(CATEGORY)); p.setCategory(command.stringValueOfParameterNamed(CATEGORY));
p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE))); p.setSubType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)));
p.setIntRate(script.getIntRate()); p.setIntRate(script.getIntRate());
@@ -160,6 +180,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setOtherCharges(zeroIfNull(command.bigDecimalValueOfParameterNamed(OTHER_CHARGES))); p.setOtherCharges(zeroIfNull(command.bigDecimalValueOfParameterNamed(OTHER_CHARGES)));
if ("MUTUAL_FUND".equals(bondType)) { if ("MUTUAL_FUND".equals(bondType)) {
computeMutualFund(p, command); computeMutualFund(p, command);
} else if (nonSlr) {
computeNonSlrBond(p, command, script);
} else { } else {
computeSecurity(p, command, script); computeSecurity(p, command, script);
} }
@@ -167,7 +189,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
if (nonSlr) { if (nonSlr) {
final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue()); final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue());
if (!limit.isWithinLimits()) { 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) { public CommandProcessingResult deletePurchase(final Long purchaseId) {
final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId) final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(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())) { 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"); 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 faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE); final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE); final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
if (lastIntDate.isAfter(p.getDealDate())) { // Broken-period interest and the coupon schedule both run from the last coupon on or before SETTLEMENT (a coupon
throw rule("lastint.after.deal", "Last interest date cannot be after the deal date"); // 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 units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, 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.setLastIntDate(lastIntDate);
p.setAccruedDays(days); p.setAccruedDays(days);
p.setAccruedInterest(accrued); 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. */ /** 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()); 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. */ /** 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 issue, final LocalDate maturity) { static BigDecimal approxYtm(final BigDecimal coupon, final BigDecimal price, final LocalDate from, final LocalDate maturity) {
if (coupon.signum() == 0 || issue == null || maturity == null || !maturity.isAfter(issue)) { if (coupon.signum() == 0 || from == null || maturity == null || !maturity.isAfter(from)) {
return coupon; 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; final double ytm = (coupon.doubleValue() + (100 - price.doubleValue()) / years) / price.doubleValue() * 100;
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP); return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP);
} }
@@ -84,6 +84,10 @@ public class InvestmentSaleQuoter {
if (r.party() != null && !r.party().isActive()) { if (r.party() != null && !r.party().isActive()) {
throw rule("party.inactive", "Party " + r.party().getPartyCode() + " is inactive"); 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())) { 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)); 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"); throw rule("deal.future", "The sale deal date cannot be after today");
} }
final LocalDate settlementDate = r.dealDate().plusDays(Long.parseLong(r.settlementType().substring(2))); 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) throw rule("sell.at.maturity", "The security matures on " + p.getMaturityDate().format(DATE)
+ "; on or after maturity it is redeemed, not sold"); + "; on or after maturity it is redeemed, not sold");
} }
@@ -106,14 +110,31 @@ public class InvestmentSaleQuoter {
+ " are available to sell"); + " 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 unitValue = p.getScript().getFaceValuePerUnit();
final BigDecimal face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP); final BigDecimal face;
final BigDecimal consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP); final BigDecimal consideration;
final LocalDate lastCoupon = lastCoupon(p, settlementDate); if (mutualFund) {
final int days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate); // A fund is sold / redeemed at its NAV per unit; its "face" is the cost of the units sold.
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP);
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(face, rate, days); face = p.getUnits().signum() == 0 ? BigDecimal.ZERO
final BigDecimal receivable = receivableShare(p, lastCoupon, settlementDate, r.units()).min(accrued); : 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; 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). // Cost is the units' share of the book value as it stands now (after earlier sales and write-offs).
final BigDecimal book = remainingBook(p); final BigDecimal book = remainingBook(p);
@@ -152,6 +173,10 @@ public class InvestmentSaleQuoter {
/** Repayment at maturity of all units still held, dated the maturity date. */ /** Repayment at maturity of all units still held, dated the maturity date. */
public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) { public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) {
requireActiveDebtHolding(p); 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())) { if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE) throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE)
+ "; it can be redeemed on or after that date"); + "; it can be redeemed on or after that date");
@@ -274,8 +299,8 @@ public class InvestmentSaleQuoter {
if (!STATUS_ACTIVE.equals(p.getStatus())) { if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding"); throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding");
} }
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) { 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, Non-SLR and Bond holdings"); 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.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.service.DateUtils; 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.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; 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()) .debit(this.journalPoster.glId(BROKERAGE_GL_CODE), s.getBrokerage())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared())) .credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), 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(), final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment sale settled: deal " + p.getDealNo() + ", " + s.getUnits().stripTrailingZeros().toPlainString() + " units of " "Investment sale settled: deal " + p.getDealNo() + ", " + s.getUnits().stripTrailingZeros().toPlainString() + " units of "
+ p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc() + " to " + s.getParty().getPartyName(), + 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()) final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines().debit(s.getBankGlAccount().getId(), s.getNetAmount())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared())) .credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), 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(), final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment redeemed at maturity: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " "Investment redeemed at maturity: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " "
+ p.getScript().getScriptDesc(), + p.getScript().getScriptDesc(),
@@ -243,14 +245,24 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
} }
/** Profit is credited to income; a shortfall is debited to {@code lossGlCode}. */ /** 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) { 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) { } else if (profitLoss.signum() < 0) {
lines.debit(this.journalPoster.glId(lossGlCode), profitLoss.negate()); 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) { private InvestmentSaleQuoter.SellRequest sellRequest(final JsonCommand command) {
final Long partyId = command.longValueOfParameterNamed(PARTY_ID); final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId)); 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.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; 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.data.InvestmentSecurityTransferData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
@@ -90,8 +89,12 @@ public class InvestmentSecurityTransferService {
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID); final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript to = this.scriptRepository.findById(scriptId).orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId)); final InvestmentScript to = this.scriptRepository.findById(scriptId).orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final InvestmentScript from = p.getScript(); final InvestmentScript from = p.getScript();
if (!STATUS_ACTIVE.equals(p.getStatus()) || !InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) { if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active Govt Security / Bond holding"); 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) { 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"); throw rule("pending.sale", "Deal " + p.getDealNo() + " has a pending sale; settle or drop it first");