feat(investment): non-SLR rating, 10% limits and debt-only mutual funds
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 8 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 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-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

This commit is contained in:
2026-10-23 12:40:43 +05:30
parent 13ad567a8b
commit 140c76dac9
11 changed files with 401 additions and 2 deletions
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.investment.api; package org.apache.fineract.portfolio.investment.api;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@@ -47,6 +48,26 @@ public final class InvestmentApiConstants {
public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND", public static final Map<String, String> SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND",
"M", "FD", "F", "SHARE", "S"); "M", "FD", "F", "SHARE", "S");
/** Bond types that are non-SLR investments, counted against the RBI limit of 10% of deposits. */
public static final Set<String> NON_SLR_BOND_TYPES = Set.of("NON_SLR", "BOND", "MUTUAL_FUND");
/** Bond types that must carry a credit rating: corporate bonds, CPs and CDs. */
public static final Set<String> RATED_BOND_TYPES = Set.of("NON_SLR", "BOND");
/** Credit ratings on the Script Master: long-term (bonds) and short-term (CP / CD) scales, best first. */
public static final List<String> RATINGS = List.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "BBB+", "BBB", "BBB-", "BB+", "BB",
"BB-", "B+", "B", "B-", "C", "D", "A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4", "UNRATED");
/** 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");
/** Credit rating agencies registered with SEBI. */
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
"INFOMERICS");
/** RBI: only debt and money-market mutual funds; their sub types start with this. */
public static final String DEBT_MF_SUB_TYPE_PREFIX = "DEBT";
public static final String PARTY_CODE_PREFIX = "PTY-"; public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters // Common parameters
@@ -87,6 +108,9 @@ public final class InvestmentApiConstants {
public static final String MATURITY_DATE = "maturityDate"; public static final String MATURITY_DATE = "maturityDate";
public static final String COMPANY_NAME = "companyName"; public static final String COMPANY_NAME = "companyName";
public static final String CORP_CODE = "corpCode"; public static final String CORP_CODE = "corpCode";
public static final String RATING = "rating";
public static final String RATING_AGENCY = "ratingAgency";
public static final String LISTED = "listed";
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE, BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
@@ -94,5 +118,5 @@ public final class InvestmentApiConstants {
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT, public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE, INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
ACTIVE, LOCALE, DATE_FORMAT); RATING, RATING_AGENCY, LISTED, ACTIVE, LOCALE, DATE_FORMAT);
} }
@@ -30,6 +30,7 @@ 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.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.List; import java.util.List;
@@ -39,10 +40,13 @@ 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;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData; import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@@ -57,6 +61,7 @@ public class InvestmentPurchaseApiResource {
private final InvestmentPurchaseReadPlatformService readPlatformService; private final InvestmentPurchaseReadPlatformService readPlatformService;
private final InvestmentSaleReadPlatformService saleReadPlatformService; private final InvestmentSaleReadPlatformService saleReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final InvestmentNonSlrService nonSlrService;
@GET @GET
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@@ -66,6 +71,20 @@ public class InvestmentPurchaseApiResource {
return this.readPlatformService.retrieveAll(); return this.readPlatformService.retrieveAll();
} }
@GET
@Path("non-slr-limit")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "RBI non-SLR limits for a purchase", description = "Where a non-SLR purchase of amount (book value) in scriptId "
+ "on dealDate (dd-MM-yyyy, default today) stands against the 10% limits; any of them may be left out.")
public InvestmentNonSlrLimitData nonSlrLimit(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId,
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate date = StringUtils.isBlank(dealDate) ? DateUtils.getBusinessLocalDate()
: LocalDate.parse(dealDate.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy"));
return this.nonSlrService.limits(date, scriptId, amount);
}
@GET @GET
@Path("{purchaseId}") @Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@@ -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.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/** Where a new non-SLR purchase stands against the RBI limits on non-SLR and unlisted non-SLR investments. */
@Getter
@Builder
public class InvestmentNonSlrLimitData {
/** The 31 March the limits are based on. */
private final String asOnDate;
/** Total deposits as on that date, as entered for the FD limits; empty until someone enters it. */
private final BigDecimal totalDeposits;
/** 10% of total deposits. */
private final BigDecimal nonSlrLimit;
/** Book value of non-SLR investments held or pending settlement, before this deal. */
private final BigDecimal nonSlrOutstanding;
private final BigDecimal nonSlrAfter;
/** Book value of non-SLR investments held on that 31 March. */
private final BigDecimal nonSlrBase;
/** 10% of the non-SLR investments held on that 31 March. */
private final BigDecimal unlistedLimit;
private final BigDecimal unlistedOutstanding;
private final BigDecimal unlistedAfter;
private final BigDecimal newAmount;
/** Whether this deal's security is listed or proposed to be listed; unlisted ones also count against the unlisted limit. */
private final boolean listed;
private final boolean withinLimits;
private final String message;
}
@@ -54,6 +54,9 @@ public class InvestmentScriptData {
private final String phone; private final String phone;
private final String fax; private final String fax;
private final String corpCode; private final String corpCode;
private final String rating;
private final String ratingAgency;
private final boolean listed;
private final boolean active; private final boolean active;
public static InvestmentScriptData from(final InvestmentScript s) { public static InvestmentScriptData from(final InvestmentScript s) {
@@ -63,7 +66,8 @@ public class InvestmentScriptData {
.faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate())) .faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate()))
.maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1()) .maturityDate(format(s.getMaturityDate())).companyName(s.getCompanyName()).address1(s.getAddress1())
.address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone()) .address2(s.getAddress2()).address3(s.getAddress3()).city(s.getCity()).pincode(s.getPincode()).phone(s.getPhone())
.fax(s.getFax()).corpCode(s.getCorpCode()).active(s.isActive()).build(); .fax(s.getFax()).corpCode(s.getCorpCode()).rating(s.getRating()).ratingAgency(s.getRatingAgency()).listed(s.isListed())
.active(s.isActive()).build();
} }
private static String format(final LocalDate date) { private static String format(final LocalDate date) {
@@ -102,6 +102,17 @@ public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom<Lon
@Column(name = "corp_code", length = 30) @Column(name = "corp_code", length = 30)
private String corpCode; private String corpCode;
/** Credit rating, long-term (AAA … D) for bonds or short-term (A1+ … A4) for CPs / CDs; UNRATED when none. */
@Column(name = "rating", length = 10)
private String rating;
@Column(name = "rating_agency", length = 30)
private String ratingAgency;
/** Listed on an exchange, or proposed to be listed (RBI counts those as listed when bought). */
@Column(name = "is_listed", nullable = false)
private boolean listed = true;
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private boolean active = true; private boolean active = true;
} }
@@ -41,11 +41,17 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LISTED;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_PARAMS; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATED_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATING;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATINGS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATING_AGENCIES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATING_AGENCY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_PARTY; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_PARTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_SCRIPT; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RESOURCE_SCRIPT;
@@ -200,10 +206,29 @@ 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));
validateAddress(element, v); validateAddress(element, v);
throwIfErrors(errors); throwIfErrors(errors);
} }
/** Corporate bonds, CPs and CDs carry their credit rating, so a purchase can be checked against the RBI minimum. */
private void validateRating(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
final String rating = this.fromApiJsonHelper.extractStringNamed(RATING, element);
if (required) {
v.reset().parameter(RATING).value(rating).notBlank();
}
if (StringUtils.isNotBlank(rating)) {
v.reset().parameter(RATING).value(rating).isOneOfTheseValues(RATINGS.toArray());
}
final String agency = this.fromApiJsonHelper.extractStringNamed(RATING_AGENCY, element);
if (StringUtils.isNotBlank(agency)) {
v.reset().parameter(RATING_AGENCY).value(agency).isOneOfTheseValues(RATING_AGENCIES.toArray());
}
if (this.fromApiJsonHelper.parameterExists(LISTED, element)) {
v.reset().parameter(LISTED).value(this.fromApiJsonHelper.extractBooleanNamed(LISTED, element)).notNull();
}
}
// -------------------- shared -------------------- // -------------------- shared --------------------
private void validateBondType(final JsonElement element, final DataValidatorBuilder v, final boolean required) { private void validateBondType(final JsonElement element, final DataValidatorBuilder v, final boolean required) {
@@ -40,11 +40,14 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LISTED;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_CODE_PREFIX; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PARTY_NAME;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PHONE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.PINCODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATING;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATING_AGENCY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
@@ -288,6 +291,11 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
setIfPresent(command, PHONE, s::setPhone); setIfPresent(command, PHONE, s::setPhone);
setIfPresent(command, FAX, s::setFax); setIfPresent(command, FAX, s::setFax);
setIfPresent(command, CORP_CODE, s::setCorpCode); setIfPresent(command, CORP_CODE, s::setCorpCode);
setIfPresent(command, RATING, s::setRating);
setIfPresent(command, RATING_AGENCY, s::setRatingAgency);
if (command.parameterExists(LISTED)) {
s.setListed(command.booleanPrimitiveValueOfParameterNamed(LISTED));
}
if (command.parameterExists(FACE_VALUE_PER_UNIT) && command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT) != null) { if (command.parameterExists(FACE_VALUE_PER_UNIT) && command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT) != null) {
s.setFaceValuePerUnit(command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT)); s.setFaceValuePerUnit(command.bigDecimalValueOfParameterNamed(FACE_VALUE_PER_UNIT));
} }
@@ -0,0 +1,200 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.DEBT_MF_SUB_TYPE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ELIGIBLE_RATINGS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.NON_SLR_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RATED_BOND_TYPES;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBase;
import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* RBI rules on non-SLR investments (corporate bonds, CPs, CDs and mutual funds). What may be bought: bonds rated "A" or
* higher, CPs and CDs rated "A2" or higher, and only debt or money-market mutual funds. How much: non-SLR investments
* within 10% of total deposits as on the previous 31 March, and unlisted ones within 10% of the non-SLR investments held
* on that date; while unlisted ones are above their limit, no further non-SLR investment is made.
*/
@Service
@RequiredArgsConstructor
public class InvestmentNonSlrService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final BigDecimal LIMIT_PERCENT = BigDecimal.TEN;
private static final String MUTUAL_FUND = "MUTUAL_FUND";
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentDepositBaseRepository depositBaseRepository;
private final InvestmentScriptRepository scriptRepository;
/** Blocks a non-SLR purchase the RBI does not permit: a bond, CP or CD below the minimum rating, or a non-debt mutual fund. */
public void requireEligible(final InvestmentScript script, final String subType) {
if (RATED_BOND_TYPES.contains(script.getBondType())) {
if (StringUtils.isBlank(script.getRating())) {
throw rule("rating.missing", "Enter the credit rating of script " + script.getScriptCode() + " in Script Master first; "
+ "RBI allows bonds rated A or higher and CP / CD rated A2 or higher");
}
if (!ELIGIBLE_RATINGS.contains(script.getRating())) {
throw rule("rating.below.minimum", "Script " + script.getScriptCode() + " is rated " + script.getRating()
+ "; RBI allows bonds rated A or higher and CP / CD rated A2 or higher");
}
}
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");
}
}
/** The RBI limits before a purchase is saved, for the Purchase Entry screen; the script and amount may be left out. */
@Transactional(readOnly = true)
public InvestmentNonSlrLimitData limits(final LocalDate dealDate, final Long scriptId, final BigDecimal amount) {
final InvestmentScript script = scriptId == null ? null
: this.scriptRepository.findById(scriptId).orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
return limits(dealDate, script, amount);
}
/** The RBI limits for a non-SLR purchase of {@code amount} (book value) in {@code script} (either may be empty) on {@code dealDate}. */
@Transactional(readOnly = true)
public InvestmentNonSlrLimitData limits(final LocalDate dealDate, final InvestmentScript script, final BigDecimal amount) {
final LocalDate base = InvestmentFdService.previousYearEnd(dealDate);
final BigDecimal total = this.depositBaseRepository.findByAsOnDate(base).map(InvestmentDepositBase::getTotalDeposits).orElse(null);
final BigDecimal newAmount = amount == null ? BigDecimal.ZERO : amount;
final boolean listed = script == null || !unlisted(script);
BigDecimal outstanding = BigDecimal.ZERO;
BigDecimal unlistedOutstanding = BigDecimal.ZERO;
BigDecimal nonSlrBase = BigDecimal.ZERO;
for (final InvestmentPurchase p : this.purchaseRepository.findAll()) {
if (!NON_SLR_BOND_TYPES.contains(p.getBondType())) {
continue;
}
final BigDecimal now = bookNow(p);
outstanding = outstanding.add(now);
if (unlisted(p.getScript())) {
unlistedOutstanding = unlistedOutstanding.add(now);
}
nonSlrBase = nonSlrBase.add(bookOn(p, base));
}
final BigDecimal nonSlrAfter = outstanding.add(newAmount);
final BigDecimal unlistedAfter = listed ? unlistedOutstanding : unlistedOutstanding.add(newAmount);
final BigDecimal unlistedLimit = percent(nonSlrBase);
final InvestmentNonSlrLimitData.InvestmentNonSlrLimitDataBuilder b = InvestmentNonSlrLimitData.builder().asOnDate(base.format(DATE))
.totalDeposits(total).nonSlrOutstanding(outstanding).nonSlrAfter(nonSlrAfter).nonSlrBase(nonSlrBase)
.unlistedLimit(unlistedLimit).unlistedOutstanding(unlistedOutstanding).unlistedAfter(unlistedAfter).newAmount(newAmount)
.listed(listed);
if (total == null) {
return b.withinLimits(false).message("Enter the bank's total deposits as on " + base.format(DATE)
+ " first; the RBI limit on non-SLR investments is worked out from it").build();
}
final BigDecimal nonSlrLimit = percent(total);
b.nonSlrLimit(nonSlrLimit);
if (nonSlrAfter.compareTo(nonSlrLimit) > 0) {
return b.withinLimits(false).message("Non-SLR investments would be " + plain(nonSlrAfter) + ", above the RBI limit of "
+ plain(nonSlrLimit) + " (10% of total deposits " + plain(total) + " as on " + base.format(DATE) + ")").build();
}
if (unlistedOutstanding.compareTo(unlistedLimit) > 0) {
return b.withinLimits(false).message("Unlisted non-SLR investments are " + plain(unlistedOutstanding)
+ ", above the RBI limit of " + plain(unlistedLimit) + " (10% of non-SLR investments " + plain(nonSlrBase) + " as on "
+ base.format(DATE) + "); no further non-SLR investment until they are within it").build();
}
if (!listed && unlistedAfter.compareTo(unlistedLimit) > 0) {
return b.withinLimits(false)
.message("Unlisted non-SLR investments would be " + plain(unlistedAfter) + ", above the RBI limit of "
+ plain(unlistedLimit) + " (10% of non-SLR investments " + plain(nonSlrBase) + " as on " + base.format(DATE)
+ ")")
.build();
}
return b.withinLimits(true).message("Within the RBI limits").build();
}
/** Only bonds, CPs and CDs can be unlisted for the RBI limit; mutual fund units are not reckoned there. */
private static boolean unlisted(final InvestmentScript s) {
return RATED_BOND_TYPES.contains(s.getBondType()) && !s.isListed();
}
/** Book value now of a deal held or pending settlement: cost less what settled sales, amortisation and write-offs took off. */
private BigDecimal bookNow(final InvestmentPurchase p) {
if (InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
return p.getBookValue();
}
if (!InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus())) {
return BigDecimal.ZERO;
}
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;
}
/** Book value of a deal on {@code date}: settled by then, with units sold or redeemed after it added back. */
private BigDecimal bookOn(final InvestmentPurchase p, final LocalDate date) {
if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus()) || p.getSettlementDate().isAfter(date)) {
return BigDecimal.ZERO;
}
BigDecimal units = p.getBalanceUnits();
BigDecimal book = p.getBookValue();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (!InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus())) {
continue;
}
if (s.getSettlementDate().isAfter(date)) {
units = units.add(s.getUnits());
} else if (s.getBookValueSold() != null) {
book = book.subtract(s.getBookValueSold());
}
}
return units.signum() > 0 ? book : BigDecimal.ZERO;
}
private static BigDecimal percent(final BigDecimal total) {
return total.multiply(LIMIT_PERCENT).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
private static String plain(final BigDecimal v) {
return v.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investment.nonslr." + code, message);
}
}
@@ -18,6 +18,7 @@
*/ */
package org.apache.fineract.portfolio.investment.service; package org.apache.fineract.portfolio.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.NON_SLR_BOND_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKEN_PERIOD_INTEREST_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKEN_PERIOD_INTEREST_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
@@ -60,6 +61,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.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
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;
@@ -91,6 +93,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
private final GLAccountRepositoryWrapper glAccountRepository; private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final InvestmentJournalPoster journalPoster; private final InvestmentJournalPoster journalPoster;
private final InvestmentNonSlrService nonSlrService;
@Override @Override
@Transactional @Transactional
@@ -119,6 +122,10 @@ 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");
} }
final boolean nonSlr = NON_SLR_BOND_TYPES.contains(bondType);
if (nonSlr) {
this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE));
}
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE); final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) { if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) {
@@ -157,6 +164,12 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
computeSecurity(p, command, script); computeSecurity(p, command, script);
} }
p.setSettlementAmount(p.getBookValue().add(p.getAccruedInterest()).add(totalCharges(p))); p.setSettlementAmount(p.getBookValue().add(p.getAccruedInterest()).add(totalCharges(p)));
if (nonSlr) {
final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue());
if (!limit.isWithinLimits()) {
throw rule("nonslr.limit.exceeded", limit.getMessage());
}
}
final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p); final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
@@ -200,4 +200,5 @@
<include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/> <include file="parts/0606_add_ctr_monthly_report_job.xml" relativeToChangelogFile="true"/>
<include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/> <include file="parts/0607_cc_relevant_risk_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/> <include file="parts/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
<include file="parts/0609_investment_script_rating.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,44 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
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.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
RBI: a UCB may buy corporate bonds rated "A" or higher and CPs / CDs rated "A2" or higher, and unlisted non-SLR
securities are limited to 10% of its non-SLR investments. The Script Master records each security's credit
rating, the agency that gave it and whether it is listed (or proposed to be listed), so a purchase can be checked.
-->
<changeSet author="cbs-anshul" id="0609-1-investment-script-rating">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_script" columnName="rating"/></not>
</preConditions>
<addColumn tableName="m_investment_script">
<column name="rating" type="VARCHAR(10)"/>
<column name="rating_agency" type="VARCHAR(30)"/>
<column name="is_listed" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>