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