Compare commits

...
3 Commits
Author SHA1 Message Date
anshulandClaude Opus 5.5 6380e0d7d2 feat(investment): broken-period interest as an expense in the interest schedule
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
RBI UCB Investment Directions 2025, para 152: broken-period interest paid
to the seller on G-Sec / approved securities is not part of cost but an
expense (confirmed by Jitendra Sir; GL 500606). The interest schedule
therefore no longer carries it as a receivable to be cleared by the first
coupon: the first coupon is income in full. Tests updated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:07 +05:30
anshulandClaude Opus 5.5 4ad508ee96 feat(investment): Daily Script Master backend
Market rate of each script for a day (open / high / low / close), as the
bank's existing CBS keeps it, so Purchase Entry can take its rate from
here instead of the dealer typing it (Jitendra Sir).

- m_investment_daily_rate, one row per script per day (migration 0596),
  with READ/CREATE/DELETE permissions granted to Investment menu roles.
- /v1/investment/daily-rates: list (optionally per script), save, drop,
  and /latest for the newest rate of a script on or before a date.
- Close rate is mandatory; high may not be below low, and open / close
  must sit inside low-high, with readable messages.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 15:27:07 +05:30
anshulandClaude Opus 5.5 6eb562548c feat(investment): keep purchase charges out of book value
Brokerage, stamp duty and the new other charges are expenses of the deal
(Jitendra Sir): they are stored on the purchase and added to the
settlement amount, but no longer to the book value, for securities and
mutual funds alike. Settlement = book value + accrued interest + charges.

Adds m_investment_purchase.other_charges (migration 0595) and the
otherCharges request/response field.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-12 11:54:59 +05:30
25 changed files with 892 additions and 43 deletions
@@ -151,6 +151,22 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentDailyRate() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTDAILYRATE";
this.entityId = null;
this.href = "/investment/daily-rates";
return this;
}
public CommandWrapperBuilder deleteInvestmentDailyRate(final Long rateId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTDAILYRATE";
this.entityId = rateId;
this.href = "/investment/daily-rates/" + rateId;
return this;
}
public CommandWrapperBuilder createChequeStockLot() {
this.actionName = "CREATE";
this.entityName = "CHEQUESTOCKLOT";
@@ -0,0 +1,41 @@
/**
* 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.util.Set;
/** Constants for the Daily Script Master (market rate of a script for a day). */
public final class InvestmentDailyRateApiConstants {
private InvestmentDailyRateApiConstants() {}
public static final String ENTITY = "INVESTMENTDAILYRATE";
public static final String RESOURCE = "investmentdailyrate";
public static final String SCRIPT_ID = "scriptId";
public static final String RATE_DATE = "rateDate";
public static final String OPEN_RATE = "openRate";
public static final String HIGH_RATE = "highRate";
public static final String LOW_RATE = "lowRate";
public static final String CLOSE_RATE = "closeRate";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(SCRIPT_ID, RATE_DATE, OPEN_RATE, HIGH_RATE, LOW_RATE, CLOSE_RATE, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,96 @@
/**
* 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 io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
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.commons.lang3.StringUtils;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentDailyRateData;
import org.apache.fineract.portfolio.investment.service.InvestmentDailyRateReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/daily-rates")
@Component
@Tag(name = "Investment Daily Script Master", description = "Market rate of each script for a day; Purchase Entry takes its rate from here.")
@RequiredArgsConstructor
public class InvestmentDailyRateApiResource {
private final PlatformSecurityContext context;
private final InvestmentDailyRateReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List daily script rates, newest first")
public List<InvestmentDailyRateData> retrieveAll(@QueryParam("scriptId") @Parameter(description = "scriptId") final Long scriptId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentDailyRateApiConstants.ENTITY);
return this.readPlatformService.retrieveAll(scriptId);
}
@GET
@Path("latest")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Newest rate of a script on or before a date", description = "onDate (dd-MM-yyyy) defaults to the business "
+ "date. Returns no content when no rate has been entered for the script.")
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"));
return this.readPlatformService.retrieveLatest(scriptId, on);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a daily script rate")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentDailyRate().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{rateId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a daily script rate")
public CommandProcessingResult delete(@PathParam("rateId") @Parameter(description = "rateId") final Long rateId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentDailyRate(rateId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -45,6 +45,7 @@ public final class InvestmentPurchaseApiConstants {
public static final String NAV = "nav";
public static final String STAMP_DUTY = "stampDuty";
public static final String BROKERAGE = "brokerage";
public static final String OTHER_CHARGES = "otherCharges";
public static final String CATEGORY = "category";
public static final String LAST_INT_DATE = "lastIntDate";
public static final String CR_GL_ACCOUNT_ID = "crGlAccountId";
@@ -55,6 +56,6 @@ public final class InvestmentPurchaseApiConstants {
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE, OTHER_DETAILS,
LOCALE, DATE_FORMAT);
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
OTHER_DETAILS, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate;
@Getter
@Builder
public class InvestmentDailyRateData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long scriptId;
private final String scriptCode;
private final String scriptDesc;
private final String bondType;
private final String rateDate;
private final BigDecimal openRate;
private final BigDecimal highRate;
private final BigDecimal lowRate;
private final BigDecimal closeRate;
public static InvestmentDailyRateData from(final InvestmentDailyRate r) {
return InvestmentDailyRateData.builder().id(r.getId()).scriptId(r.getScript().getId()).scriptCode(r.getScript().getScriptCode())
.scriptDesc(r.getScript().getScriptDesc()).bondType(r.getScript().getBondType())
.rateDate(r.getRateDate().format(DATE_FORMAT)).openRate(r.getOpenRate()).highRate(r.getHighRate()).lowRate(r.getLowRate())
.closeRate(r.getCloseRate()).build();
}
}
@@ -45,7 +45,9 @@ public class InvestmentInterestScheduleData {
private final String dealDate;
private final String lastIntDate;
private final String maturityDate;
/** Interest paid to the seller at purchase — already in Interest Receivable. */
/**
* Broken-period interest paid to the seller at purchase — booked as an expense (GL 500606), not in this schedule.
*/
private final BigDecimal purchasedAccruedInterest;
private final BigDecimal couponPerHalfYear;
private final String asOnDate;
@@ -52,6 +52,7 @@ public class InvestmentPurchaseData {
private final BigDecimal balanceUnits;
private final BigDecimal brokerage;
private final BigDecimal stampDuty;
private final BigDecimal otherCharges;
private final BigDecimal bookValue;
private final BigDecimal premiumDiscount;
private final BigDecimal intRate;
@@ -80,14 +81,14 @@ public class InvestmentPurchaseData {
.partyName(p.getParty().getPartyName()).dealDate(format(p.getDealDate())).dealTime(p.getDealTime())
.settlementType(p.getSettlementType()).settlementDate(format(p.getSettlementDate())).faceValue(p.getFaceValue())
.purchaseRate(p.getPurchaseRate()).units(p.getUnits()).balanceUnits(p.getBalanceUnits()).brokerage(p.getBrokerage())
.stampDuty(p.getStampDuty()).bookValue(p.getBookValue()).premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate())
.issueDate(format(p.getIssueDate())).maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate()))
.accruedDays(p.getAccruedDays()).accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount())
.ytm(p.getYtm()).category(p.getCategory()).subType(p.getSubType())
.investmentGlAccountId(p.getInvestmentGlAccount().getId()).investmentGlCode(p.getInvestmentGlAccount().getGlCode())
.investmentGlName(p.getInvestmentGlAccount().getName()).crGlAccountId(p.getCrGlAccount().getId())
.crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName()).otherDetails(p.getOtherDetails())
.status(p.getStatus()).build();
.stampDuty(p.getStampDuty()).otherCharges(p.getOtherCharges()).bookValue(p.getBookValue())
.premiumDiscount(p.getPremiumDiscount()).intRate(p.getIntRate()).issueDate(format(p.getIssueDate()))
.maturityDate(format(p.getMaturityDate())).lastIntDate(format(p.getLastIntDate())).accruedDays(p.getAccruedDays())
.accruedInterest(p.getAccruedInterest()).settlementAmount(p.getSettlementAmount()).ytm(p.getYtm()).category(p.getCategory())
.subType(p.getSubType()).investmentGlAccountId(p.getInvestmentGlAccount().getId())
.investmentGlCode(p.getInvestmentGlAccount().getGlCode()).investmentGlName(p.getInvestmentGlAccount().getName())
.crGlAccountId(p.getCrGlAccount().getId()).crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName())
.otherDetails(p.getOtherDetails()).status(p.getStatus()).build();
}
private static String format(final LocalDate date) {
@@ -0,0 +1,62 @@
/**
* 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.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/**
* Daily Script Master: the market rate of a script for one day, as the bank's existing CBS keeps it. Purchase Entry
* takes its rate from here (Jitendra Sir). Close rate is mandatory; open, high and low are optional.
*/
@Entity
@Table(name = "m_investment_daily_rate")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentDailyRate extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "script_id", nullable = false)
private InvestmentScript script;
@Column(name = "rate_date", nullable = false)
private LocalDate rateDate;
@Column(name = "open_rate", scale = 6, precision = 19)
private BigDecimal openRate;
@Column(name = "high_rate", scale = 6, precision = 19)
private BigDecimal highRate;
@Column(name = "low_rate", scale = 6, precision = 19)
private BigDecimal lowRate;
@Column(name = "close_rate", scale = 6, precision = 19, nullable = false)
private BigDecimal closeRate;
}
@@ -0,0 +1,34 @@
/**
* 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.domain;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentDailyRateRepository extends JpaRepository<InvestmentDailyRate, Long> {
boolean existsByScriptIdAndRateDate(Long scriptId, LocalDate rateDate);
/** The newest rate of a script on or before a date. */
Optional<InvestmentDailyRate> findFirstByScriptIdAndRateDateLessThanEqualOrderByRateDateDesc(Long scriptId, LocalDate onDate);
List<InvestmentDailyRate> findByScriptIdOrderByRateDateDesc(Long scriptId);
}
@@ -33,8 +33,8 @@ import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDa
/**
* Investment Purchase Entry — one deal (as in the bank's existing CBS "Govt.Security/Bond Entry"). Once saved it is the
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held.
* Every derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
* holding that interest, sale and redemption later work against; {@link #balanceUnits} tracks what is still held. Every
* derived figure (units, book value, accrued interest, settlement amount) is computed by the backend.
*/
@Entity
@Table(name = "m_investment_purchase")
@@ -93,6 +93,9 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<L
@Column(name = "stamp_duty", scale = 6, precision = 19)
private BigDecimal stampDuty;
@Column(name = "other_charges", scale = 6, precision = 19)
private BigDecimal otherCharges;
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@@ -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 InvestmentDailyRateNotFoundException extends AbstractPlatformResourceNotFoundException {
public InvestmentDailyRateNotFoundException(final Long id) {
super("error.msg.investmentdailyrate.id.invalid", "Daily script rate with identifier " + id + " does not exist", id);
}
}
@@ -0,0 +1,42 @@
/**
* 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.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentDailyRateWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTDAILYRATE", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentDailyRateCommandHandler implements NewCommandSourceHandler {
private final InvestmentDailyRateWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createDailyRate(command);
}
}
@@ -0,0 +1,42 @@
/**
* 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.handler;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.investment.service.InvestmentDailyRateWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTDAILYRATE", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentDailyRateCommandHandler implements NewCommandSourceHandler {
private final InvestmentDailyRateWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteDailyRate(command.entityId());
}
}
@@ -0,0 +1,103 @@
/**
* 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.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.CLOSE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.HIGH_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.LOW_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.OPEN_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.RATE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.RESOURCE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.SCRIPT_ID;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
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.springframework.stereotype.Component;
/** Validates a Daily Script Master entry: close rate is mandatory and the day's rates must sit inside low to high. */
@Component
@RequiredArgsConstructor
public final class InvestmentDailyRateCommandFromApiJsonDeserializer {
private final FromJsonHelper fromApiJsonHelper;
public void validateForCreate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
v.reset().parameter(SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(SCRIPT_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(RATE_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(RATE_DATE, element)).notNull();
final BigDecimal close = amount(CLOSE_RATE, element);
final BigDecimal open = amount(OPEN_RATE, element);
final BigDecimal high = amount(HIGH_RATE, element);
final BigDecimal low = amount(LOW_RATE, element);
v.reset().parameter(CLOSE_RATE).value(close).notNull().positiveAmount();
v.reset().parameter(OPEN_RATE).value(open).ignoreIfNull().positiveAmount();
v.reset().parameter(HIGH_RATE).value(high).ignoreIfNull().positiveAmount();
v.reset().parameter(LOW_RATE).value(low).ignoreIfNull().positiveAmount();
if (high != null && low != null && high.compareTo(low) < 0) {
v.reset().parameter(HIGH_RATE).failWithCodeAndMessage("less.than.low.rate", "High Rate cannot be below Low Rate");
}
outsideRange(v, CLOSE_RATE, "Close Rate", close, low, high);
outsideRange(v, OPEN_RATE, "Open Rate", open, low, high);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private static void outsideRange(final DataValidatorBuilder v, final String param, final String label, final BigDecimal rate,
final BigDecimal low, final BigDecimal high) {
if (rate == null) {
return;
}
if (low != null && rate.compareTo(low) < 0) {
v.reset().parameter(param).failWithCodeAndMessage("below.low.rate", label + " cannot be below Low Rate");
} else if (high != null && rate.compareTo(high) > 0) {
v.reset().parameter(param).failWithCodeAndMessage("above.high.rate", label + " cannot be above High Rate");
}
}
private BigDecimal amount(final String param, final JsonElement element) {
return this.fromApiJsonHelper.parameterExists(param, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, element)
: null;
}
}
@@ -29,6 +29,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_CHARGES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
@@ -110,6 +111,7 @@ public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
}
v.reset().parameter(BROKERAGE).value(amount(BROKERAGE, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(STAMP_DUTY).value(amount(STAMP_DUTY, element)).ignoreIfNull().zeroOrPositiveAmount();
v.reset().parameter(OTHER_CHARGES).value(amount(OTHER_CHARGES, element)).ignoreIfNull().zeroOrPositiveAmount();
final String dealTime = this.fromApiJsonHelper.extractStringNamed(DEAL_TIME, element);
if (StringUtils.isNotBlank(dealTime)) {
@@ -0,0 +1,56 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.service;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.data.InvestmentDailyRateData;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentDailyRateReadPlatformService {
/** The register shows the latest rates first; older ones are reached by picking a script. */
private static final int MAX_ROWS = 500;
private final InvestmentDailyRateRepository dailyRateRepository;
public List<InvestmentDailyRateData> retrieveAll(final Long scriptId) {
if (scriptId != null) {
return this.dailyRateRepository.findByScriptIdOrderByRateDateDesc(scriptId).stream().map(InvestmentDailyRateData::from)
.toList();
}
return this.dailyRateRepository
.findAll(PageRequest.of(0, MAX_ROWS, Sort.by(Sort.Direction.DESC, "rateDate").and(Sort.by(Sort.Direction.DESC, "id"))))
.stream().map(InvestmentDailyRateData::from).toList();
}
/** The newest rate of a script on or before {@code onDate}; null when none has been entered. */
public InvestmentDailyRateData retrieveLatest(final Long scriptId, final LocalDate onDate) {
return this.dailyRateRepository.findFirstByScriptIdAndRateDateLessThanEqualOrderByRateDateDesc(scriptId, onDate)
.map(InvestmentDailyRateData::from).orElse(null);
}
}
@@ -0,0 +1,29 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.service;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface InvestmentDailyRateWritePlatformService {
CommandProcessingResult createDailyRate(JsonCommand command);
CommandProcessingResult deleteDailyRate(Long rateId);
}
@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.CLOSE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.HIGH_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.LOW_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.OPEN_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.RATE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentDailyRateApiConstants.SCRIPT_ID;
import java.time.LocalDate;
import lombok.RequiredArgsConstructor;
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.portfolio.investment.domain.InvestmentDailyRate;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentDailyRateNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentDailyRateCommandFromApiJsonDeserializer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/** Saves and drops Daily Script Master rates. A wrong rate is corrected by dropping it and entering it again. */
@Service
@RequiredArgsConstructor
public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDailyRateWritePlatformService {
private final InvestmentDailyRateCommandFromApiJsonDeserializer validator;
private final InvestmentDailyRateRepository dailyRateRepository;
private final InvestmentScriptRepository scriptRepository;
@Override
@Transactional
public CommandProcessingResult createDailyRate(final JsonCommand command) {
this.validator.validateForCreate(command.json());
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript script = this.scriptRepository.findById(scriptId)
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final LocalDate rateDate = command.localDateValueOfParameterNamed(RATE_DATE);
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.");
}
final InvestmentDailyRate rate = new InvestmentDailyRate();
rate.setScript(script);
rate.setRateDate(rateDate);
rate.setOpenRate(command.bigDecimalValueOfParameterNamed(OPEN_RATE));
rate.setHighRate(command.bigDecimalValueOfParameterNamed(HIGH_RATE));
rate.setLowRate(command.bigDecimalValueOfParameterNamed(LOW_RATE));
rate.setCloseRate(command.bigDecimalValueOfParameterNamed(CLOSE_RATE));
final InvestmentDailyRate saved = this.dailyRateRepository.saveAndFlush(rate);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withResourceIdAsString(script.getScriptCode()).build();
}
@Override
@Transactional
public CommandProcessingResult deleteDailyRate(final Long rateId) {
final InvestmentDailyRate rate = this.dailyRateRepository.findById(rateId)
.orElseThrow(() -> new InvestmentDailyRateNotFoundException(rateId));
this.dailyRateRepository.delete(rate);
this.dailyRateRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(rateId).build();
}
}
@@ -35,6 +35,8 @@ import java.util.TreeSet;
* Interest Receivable / Cr Interest Income), even though the cash arrives at the next coupon;</li>
* <li>when the coupon arrives, the provision already booked is cleared and only the rest is new income.</li>
* </ul>
* Broken-period interest paid to the seller at purchase is an expense of its own (RBI UCB Investment Directions, para
* 152; GL 500606), so it is not carried here: the first coupon is income in full.
*/
public final class InvestmentInterestCalculator {
@@ -129,11 +131,9 @@ public final class InvestmentInterestCalculator {
* coupon date immediately before the purchase
* @param dealDate
* purchase deal date — closings before it belong to the seller
* @param purchasedAccrued
* interest paid to the seller at purchase; it already sits in Interest Receivable
*/
public static List<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
final LocalDate lastIntDate, final LocalDate dealDate, final BigDecimal purchasedAccrued) {
final LocalDate lastIntDate, final LocalDate dealDate) {
final List<ScheduleRow> rows = new ArrayList<>();
if (faceValue == null || ratePercent == null || ratePercent.signum() <= 0 || maturity == null || lastIntDate == null
|| dealDate == null || !maturity.isAfter(lastIntDate)) {
@@ -148,7 +148,7 @@ public final class InvestmentInterestCalculator {
}
final BigDecimal coupon = halfYearCoupon(faceValue, ratePercent);
BigDecimal receivable = purchasedAccrued == null ? BigDecimal.ZERO : purchasedAccrued;
BigDecimal receivable = BigDecimal.ZERO;
LocalDate previousCoupon = lastIntDate;
for (final LocalDate date : events) {
final int days = days360(previousCoupon, date);
@@ -66,7 +66,7 @@ public class InvestmentPurchaseReadPlatformService {
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
final List<InvestmentInterestScheduleData.Row> rows = InvestmentInterestCalculator
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate(), p.getAccruedInterest()).stream()
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate()).stream()
.map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date()))
.fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest())
.alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build())
@@ -28,6 +28,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.FACE_VALUE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_CHARGES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE;
@@ -131,13 +132,17 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setOtherDetails(otherDetails(command));
p.setStatus(STATUS_ACTIVE);
final BigDecimal brokerage = zeroIfNull(command.bigDecimalValueOfParameterNamed(BROKERAGE));
p.setBrokerage(brokerage);
// Brokerage, stamp duty and other charges are expenses of the deal (Jitendra Sir): paid with the
// settlement, but kept out of the book value so the charges stay visible on their own GLs.
p.setBrokerage(zeroIfNull(command.bigDecimalValueOfParameterNamed(BROKERAGE)));
p.setStampDuty(zeroIfNull(command.bigDecimalValueOfParameterNamed(STAMP_DUTY)));
p.setOtherCharges(zeroIfNull(command.bigDecimalValueOfParameterNamed(OTHER_CHARGES)));
if ("MUTUAL_FUND".equals(bondType)) {
computeMutualFund(p, command, brokerage);
computeMutualFund(p, command);
} else {
computeSecurity(p, command, script, brokerage);
computeSecurity(p, command, script);
}
p.setSettlementAmount(p.getBookValue().add(p.getAccruedInterest()).add(totalCharges(p)));
final InvestmentPurchase saved = this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
@@ -158,8 +163,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
}
/** Govt Security / Bond: face value at a price per ₹100, plus interest owed to the seller since the last coupon. */
private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script,
final BigDecimal brokerage) {
private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script) {
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal rate = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
final LocalDate lastIntDate = command.localDateValueOfParameterNamed(LAST_INT_DATE);
@@ -177,31 +181,31 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setPurchaseRate(rate);
p.setUnits(units);
p.setBalanceUnits(units);
p.setBookValue(cost.add(brokerage));
p.setBookValue(cost);
p.setPremiumDiscount(faceValue.multiply(rate.subtract(HUNDRED)).divide(HUNDRED, 2, RoundingMode.HALF_UP));
p.setLastIntDate(lastIntDate);
p.setAccruedDays(days);
p.setAccruedInterest(accrued);
p.setSettlementAmount(p.getBookValue().add(accrued));
p.setYtm(approxYtm(intRate, rate, script.getIssueDate(), script.getMaturityDate()));
}
/** Mutual fund: the amount invested buys units at the NAV; stamp duty and brokerage add to the cost. */
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command, final BigDecimal brokerage) {
/** Mutual fund: the amount invested buys units at the NAV. */
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command) {
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT);
final BigDecimal nav = command.bigDecimalValueOfParameterNamed(NAV);
final BigDecimal stampDuty = zeroIfNull(command.bigDecimalValueOfParameterNamed(STAMP_DUTY));
final BigDecimal units = amount.divide(nav, 3, RoundingMode.HALF_UP);
p.setFaceValue(amount);
p.setPurchaseRate(nav);
p.setUnits(units);
p.setBalanceUnits(units);
p.setStampDuty(stampDuty);
p.setBookValue(amount.add(stampDuty).add(brokerage));
p.setBookValue(amount);
p.setAccruedInterest(BigDecimal.ZERO);
p.setAccruedDays(0);
p.setSettlementAmount(p.getBookValue());
}
private static BigDecimal totalCharges(final InvestmentPurchase p) {
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. */
@@ -169,4 +169,6 @@
<include file="parts/0593_tds_interest_receipts_and_deductions.xml" relativeToChangelogFile="true"/>
<include file="parts/0593_investment_masters.xml" relativeToChangelogFile="true"/>
<include file="parts/0594_investment_purchase.xml" relativeToChangelogFile="true"/>
<include file="parts/0595_investment_purchase_other_charges.xml" relativeToChangelogFile="true"/>
<include file="parts/0596_investment_daily_rate.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Other charges on an investment deal (legal, SGL, CCIL, ...), kept next to brokerage and stamp
duty. All three are expenses of the deal (Jitendra Sir): they are paid with the settlement but
are not part of the book value of the investment.
-->
<changeSet author="cbs-anshul" id="0595-1-add-investment-purchase-other-charges">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_purchase" columnName="other_charges"/></not>
</preConditions>
<addColumn tableName="m_investment_purchase">
<column name="other_charges" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,108 @@
<?xml version="1.1" encoding="UTF-8" standalone="no"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.9.xsd">
<!--
Daily Script Master: the market rate of each script for a day (open / high / low / close),
entered by treasury from FBIL / CCIL. Purchase Entry takes its rate from here instead of the
dealer typing it (Jitendra Sir). One row per script per day.
-->
<changeSet author="cbs-anshul" id="0596-1-create-investment-daily-rate">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_daily_rate"/></not>
</preConditions>
<createTable tableName="m_investment_daily_rate">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="script_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="rate_date" type="DATE"><constraints nullable="false"/></column>
<column name="open_rate" type="DECIMAL(19,6)"/>
<column name="high_rate" type="DECIMAL(19,6)"/>
<column name="low_rate" type="DECIMAL(19,6)"/>
<column name="close_rate" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="created_by" type="BIGINT"><constraints nullable="false"/></column>
<column name="created_on_utc" type="DATETIME"><constraints nullable="false"/></column>
<column name="last_modified_by" type="BIGINT"><constraints nullable="false"/></column>
<column name="last_modified_on_utc" type="DATETIME"><constraints nullable="false"/></column>
</createTable>
<addUniqueConstraint tableName="m_investment_daily_rate" columnNames="script_id, rate_date"
constraintName="uq_investment_daily_rate_script_date"/>
<addForeignKeyConstraint baseTableName="m_investment_daily_rate" baseColumnNames="script_id" constraintName="fk_investment_daily_rate_script"
referencedTableName="m_investment_script" referencedColumnNames="id"/>
</changeSet>
<changeSet author="cbs-anshul" id="0596-2-investment-daily-rate-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_INVESTMENTDAILYRATE', 'CREATE_INVESTMENTDAILYRATE', 'DELETE_INVESTMENTDAILYRATE', 'CREATE_INVESTMENTDAILYRATE_CHECKER', 'DELETE_INVESTMENTDAILYRATE_CHECKER')</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="READ_INVESTMENTDAILYRATE"/>
<column name="entity_name" value="INVESTMENTDAILYRATE"/>
<column name="action_name" value="READ"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTDAILYRATE"/>
<column name="entity_name" value="INVESTMENTDAILYRATE"/>
<column name="action_name" value="CREATE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTDAILYRATE"/>
<column name="entity_name" value="INVESTMENTDAILYRATE"/>
<column name="action_name" value="DELETE"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="CREATE_INVESTMENTDAILYRATE_CHECKER"/>
<column name="entity_name" value="INVESTMENTDAILYRATE"/>
<column name="action_name" value="CREATE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="DELETE_INVESTMENTDAILYRATE_CHECKER"/>
<column name="entity_name" value="INVESTMENTDAILYRATE"/>
<column name="action_name" value="DELETE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Same roles as the other investment screens: whoever sees the Investment menu. -->
<changeSet author="cbs-anshul" id="0596-3-grant-investment-daily-rate-permissions">
<sql>
INSERT INTO m_role_permission (role_id, permission_id)
SELECT rp.role_id, p.id
FROM m_role_permission rp
JOIN m_permission menu ON menu.id = rp.permission_id AND menu.code = 'MENU_INVESTMENT'
CROSS JOIN m_permission p
WHERE p.code IN ('READ_INVESTMENTDAILYRATE', 'CREATE_INVESTMENTDAILYRATE', 'DELETE_INVESTMENTDAILYRATE')
AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id)
</sql>
</changeSet>
</databaseChangeLog>
@@ -100,15 +100,15 @@ class InvestmentInterestCalculatorTest {
}
@Test
void scheduleClearsPurchasedInterestAndProvisionsAtEachClosing() {
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, LAST_COUPON, DEAL,
new BigDecimal("120833"));
void scheduleProvisionsAtEachClosingAndClearsAtTheCoupon() {
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, LAST_COUPON, DEAL);
// 16 coupons + 15 closings (Mar 2027 … Mar 2034); 30-09-2026 is before the deal, so it is the seller's.
assertEquals(31, rows.size());
// First coupon: 1,20,833 paid to the seller is recovered, only the rest is our income.
assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000", "120833", "254167");
// First coupon is income in full: the 1,20,833 paid to the seller was booked as an expense at purchase (RBI
// para 152).
assertRow(rows.get(0), TYPE_COUPON, LocalDate.of(2027, 2, 10), LocalDate.of(2026, 8, 11), 180, "375000", "0", "375000");
// 31 March closing: 50 days earned since 10 Feb, nothing booked yet.
assertRow(rows.get(1), TYPE_PROVISION, LocalDate.of(2027, 3, 31), LocalDate.of(2027, 2, 11), 50, "104167", "0", "104167");
// August coupon clears the March provision.
@@ -119,25 +119,25 @@ class InvestmentInterestCalculatorTest {
assertEquals(TYPE_COUPON, last.type());
assertEquals(MATURITY, last.date());
// Over the life, income booked = all coupons − interest bought from the seller.
// Over the life, income booked = all the coupons.
final BigDecimal totalIncome = rows.stream().map(ScheduleRow::amount).reduce(BigDecimal.ZERO, BigDecimal::add);
assertEquals(new BigDecimal("375000").multiply(BigDecimal.valueOf(16)).subtract(new BigDecimal("120833")), totalIncome);
assertEquals(new BigDecimal("375000").multiply(BigDecimal.valueOf(16)), totalIncome);
}
@Test
void closingOnACouponDateIsNotProvisionedTwice() {
// Coupons on 31 Mar / 30 Sep: every closing is a coupon day, so there are no provision lines.
final List<ScheduleRow> rows = InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, LocalDate.of(2028, 3, 31),
LocalDate.of(2026, 9, 30), DEAL, BigDecimal.ZERO);
LocalDate.of(2026, 9, 30), DEAL);
assertEquals(3, rows.size());
assertTrue(rows.stream().allMatch(r -> TYPE_COUPON.equals(r.type())));
}
@Test
void scheduleIsEmptyWithoutRateOrDates() {
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, BigDecimal.ZERO, MATURITY, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, null, LAST_COUPON, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, null, DEAL, null).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, BigDecimal.ZERO, MATURITY, LAST_COUPON, DEAL).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, null, LAST_COUPON, DEAL).isEmpty());
assertTrue(InvestmentInterestCalculator.schedule(ONE_CRORE, RATE, MATURITY, null, DEAL).isEmpty());
}
@Test