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>
This commit is contained in:
2026-10-12 11:54:59 +05:30
co-authored by Claude Opus 5.5
parent 41aeb2b89a
commit 6eb562548c
7 changed files with 78 additions and 26 deletions
@@ -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);
}
@@ -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) {
@@ -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;
@@ -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)) {
@@ -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,5 @@
<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"/>
</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>