From 140c76dac9a8dfd21a3d69a7571c5735eb742e15 Mon Sep 17 00:00:00 2001 From: anshul Date: Fri, 23 Oct 2026 12:40:43 +0530 Subject: [PATCH] feat(investment): non-SLR rating, 10% limits and debt-only mutual funds --- .../api/InvestmentApiConstants.java | 26 ++- .../api/InvestmentPurchaseApiResource.java | 19 ++ .../data/InvestmentNonSlrLimitData.java | 50 +++++ .../investment/data/InvestmentScriptData.java | 6 +- .../investment/domain/InvestmentScript.java | 11 + ...tMasterCommandFromApiJsonDeserializer.java | 25 +++ ...estmentMasterWritePlatformServiceImpl.java | 8 + .../service/InvestmentNonSlrService.java | 200 ++++++++++++++++++ ...tmentPurchaseWritePlatformServiceImpl.java | 13 ++ .../tenant/final-changelog-tenant.xml | 1 + .../parts/0609_investment_script_rating.xml | 44 ++++ 11 files changed, 401 insertions(+), 2 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentNonSlrLimitData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentNonSlrService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_investment_script_rating.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java index dfef09099..a56bb40e1 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java @@ -18,6 +18,7 @@ */ package org.apache.fineract.portfolio.investment.api; +import java.util.List; import java.util.Map; import java.util.Set; @@ -47,6 +48,26 @@ public final class InvestmentApiConstants { public static final Map SCRIPT_CODE_PREFIX = Map.of("GOVT_SECURITY", "G", "NON_SLR", "N", "BOND", "B", "MUTUAL_FUND", "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 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 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 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 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 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-"; // Common parameters @@ -87,6 +108,9 @@ public final class InvestmentApiConstants { public static final String MATURITY_DATE = "maturityDate"; public static final String COMPANY_NAME = "companyName"; 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 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, @@ -94,5 +118,5 @@ public final class InvestmentApiConstants { public static final Set 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, - ACTIVE, LOCALE, DATE_FORMAT); + RATING, RATING_AGENCY, LISTED, ACTIVE, LOCALE, DATE_FORMAT); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java index 924262f06..c8f979368 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java @@ -30,6 +30,7 @@ import jakarta.ws.rs.PathParam; import jakarta.ws.rs.Produces; import jakarta.ws.rs.QueryParam; import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; import java.util.List; @@ -39,10 +40,13 @@ import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; 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.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.InvestmentSaleData; +import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService; import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService; import org.springframework.stereotype.Component; @@ -57,6 +61,7 @@ public class InvestmentPurchaseApiResource { private final InvestmentPurchaseReadPlatformService readPlatformService; private final InvestmentSaleReadPlatformService saleReadPlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + private final InvestmentNonSlrService nonSlrService; @GET @Produces({ MediaType.APPLICATION_JSON }) @@ -66,6 +71,20 @@ public class InvestmentPurchaseApiResource { 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 @Path("{purchaseId}") @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentNonSlrLimitData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentNonSlrLimitData.java new file mode 100644 index 000000000..1106400f1 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentNonSlrLimitData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentScriptData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentScriptData.java index e280e82bb..4ad90afbb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentScriptData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentScriptData.java @@ -54,6 +54,9 @@ public class InvestmentScriptData { private final String phone; private final String fax; private final String corpCode; + private final String rating; + private final String ratingAgency; + private final boolean listed; private final boolean active; public static InvestmentScriptData from(final InvestmentScript s) { @@ -63,7 +66,8 @@ public class InvestmentScriptData { .faceValuePerUnit(s.getFaceValuePerUnit()).intRate(s.getIntRate()).issueDate(format(s.getIssueDate())) .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()) - .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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentScript.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentScript.java index 75e5f7431..3289cca0c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentScript.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentScript.java @@ -102,6 +102,17 @@ public class InvestmentScript extends AbstractAuditableWithUTCDateTimeCustom 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index feef0ae32..2876d6c4f 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -18,6 +18,7 @@ */ 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.BROKEN_PERIOD_INTEREST_GL_CODE; 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.exception.GeneralPlatformDomainRuleException; 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.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; @@ -91,6 +93,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur private final GLAccountRepositoryWrapper glAccountRepository; private final JdbcTemplate jdbcTemplate; private final InvestmentJournalPoster journalPoster; + private final InvestmentNonSlrService nonSlrService; @Override @Transactional @@ -119,6 +122,10 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur if (!party.isActive()) { 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); if (script.getMaturityDate() != null && dealDate.isAfter(script.getMaturityDate())) { @@ -157,6 +164,12 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur computeSecurity(p, command, script); } 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); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 52340c7b4..dddf44693 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -200,4 +200,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_investment_script_rating.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_investment_script_rating.xml new file mode 100644 index 000000000..4f200e215 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_investment_script_rating.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + + +