feat(investment): non-SLR bonds and mutual funds, UAT fixes
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+1
-3
@@ -29,8 +29,6 @@ import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.commands.domain.CommandWrapper;
|
||||
@@ -68,7 +66,7 @@ public class InvestmentAmortisationApiResource {
|
||||
@Operation(summary = "Holdings due for amortisation on a 31 March (07 Amortize Yearly Process)", description = "fyEnd dd-MM-yyyy")
|
||||
public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
|
||||
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
|
||||
return this.amortisationService.due(LocalDate.parse(fyEnd.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy")));
|
||||
return this.amortisationService.due(InvestmentApiDates.parse(fyEnd, null, "financial year end"));
|
||||
}
|
||||
|
||||
@POST
|
||||
|
||||
+3
@@ -61,6 +61,9 @@ public final class InvestmentApiConstants {
|
||||
/** RBI: corporate bonds rated "A" or higher, CPs and CDs rated "A2" or higher. */
|
||||
public static final Set<String> ELIGIBLE_RATINGS = Set.of("AAA", "AA+", "AA", "AA-", "A+", "A", "A-", "A1+", "A1", "A2+", "A2");
|
||||
|
||||
/** Short-term scale, for CPs and CDs (issued for up to one year); bonds carry the long-term scale. */
|
||||
public static final Set<String> SHORT_TERM_RATINGS = Set.of("A1+", "A1", "A2+", "A2", "A3+", "A3", "A4+", "A4");
|
||||
|
||||
/** Credit rating agencies registered with SEBI. */
|
||||
public static final List<String> RATING_AGENCIES = List.of("CRISIL", "ICRA", "CARE", "INDIA_RATINGS", "ACUITE", "BRICKWORK",
|
||||
"INFOMERICS");
|
||||
|
||||
+50
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-2
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+8
-3
@@ -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");
|
||||
}
|
||||
|
||||
+2
-5
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+1
-4
@@ -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);
|
||||
}
|
||||
|
||||
+3
@@ -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;
|
||||
}
|
||||
|
||||
+1
-1
@@ -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. */
|
||||
|
||||
+5
-1
@@ -18,6 +18,10 @@
|
||||
*/
|
||||
package org.apache.fineract.portfolio.investment.domain;
|
||||
|
||||
import java.util.List;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {}
|
||||
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {
|
||||
|
||||
List<InvestmentSecurityTransfer> findByPurchaseIdOrderByTransferDateAscIdAsc(Long purchaseId);
|
||||
}
|
||||
|
||||
+28
@@ -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);
|
||||
}
|
||||
}
|
||||
+6
-4
@@ -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);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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();
|
||||
|
||||
+10
-2
@@ -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,
|
||||
|
||||
+15
@@ -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.");
|
||||
|
||||
+47
-9
@@ -29,6 +29,7 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentDiaryData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData;
|
||||
@@ -37,13 +38,16 @@ import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Due Date Diary: the dated events of every active investment between two dates, taken from the same schedules the
|
||||
* entry screens use, so the diary always agrees with Interest Entry, Amortize Entry and the FD screens.
|
||||
* Due Date Diary: the dated events of every investment between two dates, taken from the same schedules the entry
|
||||
* screens use, so the diary always agrees with Interest Entry, Amortize Entry and the FD screens. Open holdings show
|
||||
* what is posted and what is due; sold, redeemed and closed ones show what was posted and the redemption / closure.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
@@ -60,6 +64,7 @@ public class InvestmentDiaryService {
|
||||
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
|
||||
private final InvestmentAmortisationService amortisationService;
|
||||
private final InvestmentFdInterestService fdInterestService;
|
||||
private final InvestmentSaleRepository saleRepository;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public InvestmentDiaryData diary(final LocalDate from, final LocalDate to) {
|
||||
@@ -67,11 +72,29 @@ public class InvestmentDiaryService {
|
||||
final List<InvestmentDiaryData.Row> rows = new ArrayList<>();
|
||||
|
||||
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
|
||||
if (!InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus())) {
|
||||
if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|
||||
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
|
||||
continue;
|
||||
}
|
||||
final boolean open = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus());
|
||||
final InvestmentInterestScheduleData s = this.purchaseReadPlatformService.retrieveInterestSchedule(p.getId(), null);
|
||||
final String particulars = s.getScriptCode() + " " + s.getScriptDesc();
|
||||
if (!open) {
|
||||
// Sold out or redeemed: only what was posted, and the redemption itself.
|
||||
for (final InvestmentInterestScheduleData.Row r : s.getRows()) {
|
||||
if (r.isPosted()) {
|
||||
add(rows, from, to, today, r.getIntDate(), r.getType(), SECURITY, p.getId(), p.getDealNo(), particulars,
|
||||
"COUPON".equals(r.getType()) ? r.getGrossInterest() : r.getAmount(), true, r.getVoucherNo());
|
||||
}
|
||||
}
|
||||
for (final InvestmentSale x : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
|
||||
if (InvestmentSaleApiConstants.STATUS_SETTLED.equals(x.getStatus()) && "REDEEM".equals(x.getSaleType())) {
|
||||
add(rows, from, to, today, x.getSettlementDate().format(DATE), "MATURITY", SECURITY, p.getId(), p.getDealNo(),
|
||||
particulars, x.getNetAmount(), true, x.getVoucherNo());
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
for (final InvestmentInterestScheduleData.Row r : s.getRows()) {
|
||||
// A coupon shows the cash coming in (the full coupon); a provision shows what it books.
|
||||
final BigDecimal amount = "COUPON".equals(r.getType()) ? r.getGrossInterest() : r.getAmount();
|
||||
@@ -94,16 +117,31 @@ public class InvestmentDiaryService {
|
||||
}
|
||||
}
|
||||
|
||||
for (final InvestmentFd f : this.fdRepository.findByStatusOrderByIdAsc(InvestmentFdApiConstants.STATUS_ACTIVE)) {
|
||||
for (final InvestmentFd f : this.fdRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
|
||||
final InvestmentFdScheduleData s = this.fdInterestService.schedule(f.getId());
|
||||
final String particulars = f.getParty().getPartyName() + " · " + f.getCertNo();
|
||||
if (f.getClosedOnDate() != null) {
|
||||
// Closed (matured, premature or renewed): what was posted, and the closure with what the bank paid.
|
||||
for (final InvestmentFdScheduleData.Row r : s.getRows()) {
|
||||
if (r.isPosted()) {
|
||||
final BigDecimal tds = r.getTds() == null ? BigDecimal.ZERO : r.getTds();
|
||||
final boolean payout = InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType());
|
||||
add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars,
|
||||
payout ? r.getGrossInterest().subtract(tds) : r.getAmount(), true, r.getVoucherNo());
|
||||
}
|
||||
}
|
||||
add(rows, from, to, today, f.getClosedOnDate().format(DATE), "FD_MATURITY", FD, f.getId(), f.getDealNo(),
|
||||
particulars + " (" + String.valueOf(f.getClosureType()).toLowerCase() + ")", f.getClosureAmount(), true,
|
||||
f.getClosureVoucherNo());
|
||||
continue;
|
||||
}
|
||||
for (final InvestmentFdScheduleData.Row r : s.getRows()) {
|
||||
final boolean maturity = InvestmentFdApiConstants.LINE_MATURITY.equals(r.getLineType());
|
||||
// Maturity: the maturity amount; a payout: the interest paid (net of TDS); a provision: what it books.
|
||||
final BigDecimal amount = maturity ? f.getExpectedMaturityAmount()
|
||||
: InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType())
|
||||
? r.getGrossInterest().subtract(r.getTds() == null ? BigDecimal.ZERO : r.getTds())
|
||||
: r.getAmount();
|
||||
// Maturity: the principal and the last interest (net of TDS) the bank credits; a payout: the interest paid
|
||||
// (net of TDS); a provision: what it books.
|
||||
final BigDecimal tds = r.getTds() == null ? BigDecimal.ZERO : r.getTds();
|
||||
final BigDecimal amount = maturity ? f.getPrincipal().add(r.getGrossInterest()).subtract(tds)
|
||||
: InvestmentFdApiConstants.LINE_PAYOUT.equals(r.getLineType()) ? r.getGrossInterest().subtract(tds) : r.getAmount();
|
||||
add(rows, from, to, today, r.getIntDate(), "FD_" + r.getLineType(), FD, f.getId(), f.getDealNo(), particulars, amount,
|
||||
r.isPosted(), r.getVoucherNo());
|
||||
}
|
||||
|
||||
+6
-2
@@ -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());
|
||||
}
|
||||
|
||||
+21
-6
@@ -68,6 +68,7 @@ import org.apache.fineract.portfolio.investment.domain.InvestmentFd;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterest;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentFdNotFoundException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
@@ -97,7 +98,7 @@ public class InvestmentFdInterestService {
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public InvestmentFdScheduleData schedule(final Long fdId) {
|
||||
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
|
||||
final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
|
||||
final List<Line> lines = lines(f);
|
||||
final LocalDate today = DateUtils.getBusinessLocalDate();
|
||||
final Optional<Line> next = lines.stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type())).findFirst();
|
||||
@@ -110,7 +111,9 @@ public class InvestmentFdInterestService {
|
||||
.glTransactionId(l.posted() == null ? null : l.posted().getGlTransactionId())
|
||||
.postable(STATUS_ACTIVE.equals(f.getStatus()) && next.isPresent() && next.get() == l && !l.date().isAfter(today)).build());
|
||||
}
|
||||
return InvestmentFdScheduleData.builder().fd(InvestmentFdService.data(f)).provisioned(provisioned(f)).rows(rows).build();
|
||||
// A closed FD's provisions were cleared at closure.
|
||||
return InvestmentFdScheduleData.builder().fd(InvestmentFdService.data(f))
|
||||
.provisioned(f.getClosedOnDate() == null ? provisioned(f) : BigDecimal.ZERO).rows(rows).build();
|
||||
}
|
||||
|
||||
/** For every active FD: its next interest voucher, or its maturity, when due now or within {@code withinDays}. */
|
||||
@@ -123,14 +126,25 @@ public class InvestmentFdInterestService {
|
||||
lines(f).stream().filter(l -> l.posted() == null).findFirst().filter(l -> !l.date().isAfter(upTo))
|
||||
.ifPresent(l -> due.add(InvestmentFdDueData.builder().fdId(f.getId()).dealNo(f.getDealNo())
|
||||
.partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).lineType(l.type()).intDate(l.date().format(DATE))
|
||||
.amount(LINE_MATURITY.equals(l.type()) ? f.getExpectedMaturityAmount() : l.amount())
|
||||
// What the bank credits (maturity: principal + interest - TDS; payout: interest - TDS).
|
||||
.amount(LINE_MATURITY.equals(l.type()) ? f.getPrincipal().add(l.gross()).subtract(zero(l.tds()))
|
||||
: LINE_PAYOUT.equals(l.type()) ? l.gross().subtract(zero(l.tds())) : l.amount())
|
||||
.grossInterest(l.gross()).tds(l.tds())
|
||||
.postable(!l.date().isAfter(today)).overdue(l.date().isBefore(today)).build()));
|
||||
}
|
||||
return due;
|
||||
}
|
||||
|
||||
/** Refuses a closure on {@code date} while an interest line before it is still unposted. */
|
||||
/**
|
||||
* Refuses a closure on {@code date} while an interest line before it is still unposted, or when an interest line
|
||||
* dated after it is already posted (the closure would reverse a provision the books do not hold yet on that date).
|
||||
*/
|
||||
void requirePostedBefore(final InvestmentFd f, final LocalDate date) {
|
||||
final List<InvestmentFdInterest> posted = this.interestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId());
|
||||
if (!posted.isEmpty() && posted.get(posted.size() - 1).getIntDate().isAfter(date)) {
|
||||
throw InvestmentFdService.rule("interest.posted.after", "Interest of FD " + f.getDealNo() + " is already posted up to "
|
||||
+ posted.get(posted.size() - 1).getIntDate().format(DATE) + "; close it on or after that date");
|
||||
}
|
||||
lines(f).stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type()) && l.date().isBefore(date)).findFirst()
|
||||
.ifPresent(l -> {
|
||||
throw InvestmentFdService.rule("interest.pending", "Post the " + l.type().toLowerCase() + " of "
|
||||
@@ -142,7 +156,7 @@ public class InvestmentFdInterestService {
|
||||
public CommandProcessingResult post(final Long fdId, final JsonCommand command) {
|
||||
validate(command.json());
|
||||
final InvestmentFd f = this.fdRepository.findByIdWithLock(fdId)
|
||||
.orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist"));
|
||||
.orElseThrow(() -> new InvestmentFdNotFoundException(fdId));
|
||||
if (!STATUS_ACTIVE.equals(f.getStatus())) {
|
||||
throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase());
|
||||
}
|
||||
@@ -274,7 +288,8 @@ public class InvestmentFdInterestService {
|
||||
if (f.getClosedOnDate() == null) {
|
||||
final BigDecimal last = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, f.getMaturityDate(),
|
||||
f.getCalcType(), f.getPayout());
|
||||
out.add(new Line(LINE_MATURITY, f.getMaturityDate(), lastPaid, last, sincePaid, null, last.subtract(sincePaid), null));
|
||||
final BigDecimal tds = InvestmentFdService.tdsOnGross(f.getParty(), last);
|
||||
out.add(new Line(LINE_MATURITY, f.getMaturityDate(), lastPaid, last, sincePaid, tds, last.subtract(sincePaid), null));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
+2
-1
@@ -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. */
|
||||
|
||||
+5
-1
@@ -84,7 +84,11 @@ public class InvestmentInterestPostingWritePlatformServiceImpl implements Invest
|
||||
}
|
||||
|
||||
final List<InvestmentInterestPosting> posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(purchaseId);
|
||||
final InvestmentInterestCalculator.ScheduleRow next = this.readPlatformService.scheduleRows(p).stream()
|
||||
final List<InvestmentInterestCalculator.ScheduleRow> schedule = this.readPlatformService.scheduleRows(p);
|
||||
if (schedule.isEmpty()) {
|
||||
throw rule("no.interest.lines", "Deal " + p.getDealNo() + " (zero-coupon or mutual fund) has no interest lines to post");
|
||||
}
|
||||
final InvestmentInterestCalculator.ScheduleRow next = schedule.stream()
|
||||
.filter(r -> posted.stream().noneMatch(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())))
|
||||
.findFirst().orElseThrow(() -> rule("all.posted", "All interest lines of deal " + p.getDealNo() + " are already posted"));
|
||||
if (!next.type().equals(lineType) || !next.date().equals(intDate)) {
|
||||
|
||||
+16
-3
@@ -24,6 +24,7 @@ import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
|
||||
@@ -61,6 +62,7 @@ public class InvestmentInterestReportService {
|
||||
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
|
||||
private final InvestmentFdRepository fdRepository;
|
||||
private final InvestmentFdInterestRepository fdInterestRepository;
|
||||
private final InvestmentFdInterestService fdInterestService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) {
|
||||
@@ -119,7 +121,7 @@ public class InvestmentInterestReportService {
|
||||
BigDecimal receivable = BigDecimal.ZERO;
|
||||
final BigDecimal face = p.getBalanceUnits().add(soldAfterTo).multiply(p.getScript().getFaceValuePerUnit());
|
||||
final boolean heldOnTo = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus()) || soldAfterTo.signum() > 0;
|
||||
if (heldOnTo && !p.getSettlementDate().isAfter(to)) {
|
||||
if (heldOnTo && !p.getSettlementDate().isAfter(to) && !InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType())) {
|
||||
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
|
||||
LocalDate lastCoupon = p.getLastIntDate();
|
||||
for (final InvestmentInterestCalculator.ScheduleRow r : this.purchaseReadPlatformService.scheduleRows(p)) {
|
||||
@@ -140,7 +142,7 @@ public class InvestmentInterestReportService {
|
||||
provisioned = BigDecimal.ZERO;
|
||||
}
|
||||
add(rows, "SECURITY", p.getId(), p.getDealNo(), p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(), p.getStatus(),
|
||||
face.signum() > 0 ? face : p.getFaceValue(), p.getIntRate(), received, receivable, provisioned);
|
||||
p.getFaceValue(), p.getIntRate(), received, receivable, provisioned);
|
||||
}
|
||||
|
||||
private void fd(final InvestmentFd f, final LocalDate from, final LocalDate to, final List<InvestmentInterestReportData.Row> rows) {
|
||||
@@ -170,7 +172,18 @@ public class InvestmentInterestReportService {
|
||||
final boolean openOnTo = !f.getDealDate().isAfter(to) && (f.getClosedOnDate() == null || f.getClosedOnDate().isAfter(to));
|
||||
if (openOnTo) {
|
||||
final LocalDate end = f.getMaturityDate().isBefore(to) ? f.getMaturityDate() : to;
|
||||
receivable = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, end, f.getCalcType(), f.getPayout());
|
||||
// Payouts already due but not posted count at the schedule's own (paisa-rounded) amounts; interest runs on
|
||||
// from the last of them, so the receivable ties to the FD Interest Entry to the paisa.
|
||||
LocalDate periodStart = lastPaid;
|
||||
for (final InvestmentFdInterestService.Line l : this.fdInterestService.lines(f)) {
|
||||
if (InvestmentFdApiConstants.LINE_PAYOUT.equals(l.type()) && l.posted() == null && l.date().isAfter(lastPaid)
|
||||
&& !l.date().isAfter(end)) {
|
||||
receivable = receivable.add(l.gross());
|
||||
periodStart = l.date();
|
||||
}
|
||||
}
|
||||
receivable = receivable.add(InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), periodStart, end, f.getCalcType(),
|
||||
f.getPayout()));
|
||||
} else {
|
||||
provisioned = BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
+84
-2
@@ -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",
|
||||
|
||||
+37
-3
@@ -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) {
|
||||
|
||||
+27
-1
@@ -19,6 +19,7 @@
|
||||
package org.apache.fineract.portfolio.investment.service;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.math.RoundingMode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
@@ -28,8 +29,10 @@ import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
|
||||
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestPostingData;
|
||||
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
|
||||
@@ -38,6 +41,8 @@ import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPosting
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPostingRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
|
||||
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
|
||||
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.stereotype.Service;
|
||||
@@ -53,6 +58,7 @@ public class InvestmentPurchaseReadPlatformService {
|
||||
private final InvestmentPurchaseRepository purchaseRepository;
|
||||
private final InvestmentInterestPostingRepository postingRepository;
|
||||
private final GLAccountRepositoryWrapper glAccountRepository;
|
||||
private final InvestmentSaleRepository saleRepository;
|
||||
|
||||
/** All deals, newest first — the Holdings register. Dropped deals only hold their numbers and are left out. */
|
||||
public List<InvestmentPurchaseData> retrieveAll() {
|
||||
@@ -65,6 +71,17 @@ public class InvestmentPurchaseReadPlatformService {
|
||||
return InvestmentPurchaseData.from(find(purchaseId));
|
||||
}
|
||||
|
||||
/** Book value still held: cost less what settled sales, premium amortisation and write-offs took off. */
|
||||
private BigDecimal bookHeld(final InvestmentPurchase p) {
|
||||
BigDecimal book = p.getBookValue();
|
||||
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
|
||||
if (InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus()) && s.getBookValueSold() != null) {
|
||||
book = book.subtract(s.getBookValueSold());
|
||||
}
|
||||
}
|
||||
return book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still
|
||||
* held, with what has been posted. Lines on or before {@code asOnDate} (default: business date) are marked due; the
|
||||
@@ -106,7 +123,7 @@ public class InvestmentPurchaseReadPlatformService {
|
||||
return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
|
||||
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld)
|
||||
.unitValue(p.getScript().getFaceValuePerUnit()).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate())
|
||||
.bookValue(p.getBookValue()).intRate(p.getIntRate()).dealDate(format(p.getDealDate()))
|
||||
.bookValue(bookHeld(p)).intRate(p.getIntRate()).dealDate(format(p.getDealDate()))
|
||||
.lastIntDate(format(p.getLastIntDate())).maturityDate(format(p.getMaturityDate()))
|
||||
.purchasedAccruedInterest(p.getAccruedInterest())
|
||||
.couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO)
|
||||
@@ -117,6 +134,10 @@ public class InvestmentPurchaseReadPlatformService {
|
||||
|
||||
/** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */
|
||||
public List<InvestmentInterestCalculator.ScheduleRow> scheduleRows(final InvestmentPurchase p) {
|
||||
// Non-SLR (bonds, mutual funds) has no interest scheme (Jitendra Sir): its income is the gain on sale / redemption.
|
||||
if (InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType())) {
|
||||
return List.of();
|
||||
}
|
||||
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
|
||||
return InvestmentInterestCalculator.schedule(faceHeld(p), rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate());
|
||||
}
|
||||
@@ -167,7 +188,12 @@ public class InvestmentPurchaseReadPlatformService {
|
||||
return posted.stream().filter(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())).findFirst();
|
||||
}
|
||||
|
||||
/** Face value of the units held; a fund or share has no face value, so its cost held stands in for it. */
|
||||
private static BigDecimal faceHeld(final InvestmentPurchase p) {
|
||||
if ("MUTUAL_FUND".equals(p.getBondType()) || "SHARE".equals(p.getBondType())) {
|
||||
return p.getUnits().signum() == 0 ? BigDecimal.ZERO
|
||||
: p.getFaceValue().multiply(p.getBalanceUnits()).divide(p.getUnits(), 2, RoundingMode.HALF_UP);
|
||||
}
|
||||
return p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit());
|
||||
}
|
||||
|
||||
|
||||
+76
-11
@@ -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);
|
||||
}
|
||||
|
||||
+35
-10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+16
-4
@@ -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));
|
||||
|
||||
+6
-3
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user