feat(investment): settle purchases to post them to GL on the settlement date

Settlement-date accounting (RBI UCB Investment Directions, confirmed by
Jitendra Sir): a saved purchase is PENDING_SETTLEMENT with nothing in GL.
On or after its settlement date, POST /investment/purchases/{id}/settle
posts the journal entry dated the settlement date at Head Office - Dr
investment GL (book value), Dr 500606 broken-period interest, Dr charge
GLs, Cr the paying bank - through the journal entry command, which also
raises the JV voucher. The deal becomes ACTIVE and keeps the GL
transaction id and settlement day. Settle goes through maker-checker; a
settled deal can no longer be dropped.

Broken-period interest is now counted up to the settlement date instead
of the deal date, and the interest schedule starts from it.

Migration 0597 adds the settlement columns and the SETTLE permission.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-14 10:28:09 +05:30
co-authored by Claude Opus 5.5
parent 6380e0d7d2
commit 859cabd07d
12 changed files with 254 additions and 6 deletions
@@ -151,6 +151,14 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder settleInvestmentPurchase(final Long purchaseId) {
this.actionName = "SETTLE";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/settle";
return this;
}
public CommandWrapperBuilder createInvestmentDailyRate() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTDAILYRATE";
@@ -31,8 +31,17 @@ public final class InvestmentPurchaseApiConstants {
public static final Set<String> SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2");
public static final Set<String> CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)");
/** Saved deal; nothing posted to GL until it is settled. */
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
/** Settled: posted to GL on the settlement date, now a holding. */
public static final String STATUS_ACTIVE = "ACTIVE";
/** GLs the settlement entry debits besides the script's investment GL. */
public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606";
public static final String STAMP_DUTY_GL_CODE = "500601";
public static final String BROKERAGE_GL_CODE = "500602";
public static final String OTHER_CHARGES_GL_CODE = "500603";
public static final String BOND_TYPE = "bondType";
public static final String SCRIPT_ID = "scriptId";
public static final String PARTY_ID = "partyId";
@@ -95,6 +95,19 @@ public class InvestmentPurchaseApiResource {
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle a purchase", description = "On or after the settlement date: posts the purchase to GL dated the "
+ "settlement date and makes the deal an active holding.")
public CommandProcessingResult settle(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentPurchase(purchaseId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@@ -73,6 +73,8 @@ public class InvestmentPurchaseData {
private final String crGlName;
private final String otherDetails;
private final String status;
private final String glTransactionId;
private final String settledOnDate;
public static InvestmentPurchaseData from(final InvestmentPurchase p) {
return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType())
@@ -88,7 +90,8 @@ public class InvestmentPurchaseData {
.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();
.otherDetails(p.getOtherDetails()).status(p.getStatus()).glTransactionId(p.getGlTransactionId())
.settledOnDate(format(p.getSettledOnDate())).build();
}
private static String format(final LocalDate date) {
@@ -148,4 +148,11 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<L
@Column(name = "status", nullable = false, length = 20)
private String status;
/** Journal entry transaction posted when the deal was settled. */
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "settled_on_date")
private LocalDate settledOnDate;
}
@@ -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.InvestmentPurchaseWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "SETTLE")
@RequiredArgsConstructor
public class SettleInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentPurchaseWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.settlePurchase(command.entityId(), command);
}
}
@@ -130,7 +130,7 @@ public final class InvestmentInterestCalculator {
* @param lastIntDate
* coupon date immediately before the purchase
* @param dealDate
* purchase deal date — closings before it belong to the seller
* date the holding became ours (the purchase settlement date) — closings before it belong to the seller
*/
public static List<ScheduleRow> schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity,
final LocalDate lastIntDate, final LocalDate dealDate) {
@@ -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()).stream()
.schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate()).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())
@@ -26,4 +26,6 @@ public interface InvestmentPurchaseWritePlatformService {
CommandProcessingResult createPurchase(JsonCommand command);
CommandProcessingResult deletePurchase(Long purchaseId);
CommandProcessingResult settlePurchase(Long purchaseId, JsonCommand command);
}
@@ -19,7 +19,9 @@
package org.apache.fineract.portfolio.investment.service;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKEN_PERIOD_INTEREST_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID;
@@ -29,29 +31,39 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi
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_CHARGES_GL_CODE;
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;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
import com.google.gson.JsonArray;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.Locale;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
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.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@@ -82,6 +94,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
private final InvestmentPartyRepository partyRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final JdbcTemplate jdbcTemplate;
private final JournalEntryWritePlatformService journalEntryWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
@Override
@Transactional
@@ -130,7 +144,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setInvestmentGlAccount(script.getGlAccount());
p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID)));
p.setOtherDetails(otherDetails(command));
p.setStatus(STATUS_ACTIVE);
p.setStatus(STATUS_PENDING_SETTLEMENT);
// 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.
@@ -154,6 +168,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
public CommandProcessingResult deletePurchase(final Long purchaseId) {
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
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");
}
if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) {
throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped");
}
@@ -162,6 +179,78 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build();
}
/**
* Settles a deal: on or after its settlement date the money has gone and the security has come in, so the purchase
* is posted to GL dated the settlement date (settlement-date accounting) and the deal becomes a holding. Posted
* through the journal entry command like any manual entry, inside this transaction, so under maker-checker nothing
* reaches the GL until the settlement is approved.
*/
@Override
@Transactional
public CommandProcessingResult settlePurchase(final Long purchaseId, final JsonCommand command) {
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (!STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
throw rule("settle.not.pending", "Deal " + p.getDealNo() + " is not pending settlement");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
if (p.getSettlementDate().isAfter(today)) {
throw rule("settle.before.date", "Deal " + p.getDealNo() + " can be settled on or after its settlement date "
+ p.getSettlementDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
final JsonArray debits = new JsonArray();
addLine(debits, p.getInvestmentGlAccount().getId(), p.getBookValue());
addLine(debits, glId(BROKEN_PERIOD_INTEREST_GL_CODE), p.getAccruedInterest());
addLine(debits, glId(STAMP_DUTY_GL_CODE), p.getStampDuty());
addLine(debits, glId(BROKERAGE_GL_CODE), p.getBrokerage());
addLine(debits, glId(OTHER_CHARGES_GL_CODE), p.getOtherCharges());
final JsonArray credits = new JsonArray();
addLine(credits, p.getCrGlAccount().getId(), p.getSettlementAmount());
final String dateFormat = "dd MMMM yyyy";
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", headOfficeId());
payload.addProperty("transactionDate", p.getSettlementDate().format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
payload.addProperty("dateFormat", dateFormat);
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", "INR");
payload.addProperty("referenceNumber", p.getDealNo());
payload.addProperty("comments", "Investment purchase settled: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " "
+ p.getScript().getScriptDesc() + ", " + p.getParty().getPartyName());
payload.add("debits", debits);
payload.add("credits", credits);
final String json = payload.toString();
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
null, null, null, null, null, null, null, null, null, null, null, null, null);
final CommandProcessingResult posted = this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
p.setGlTransactionId(posted.getTransactionId());
p.setSettledOnDate(today);
p.setStatus(STATUS_ACTIVE);
this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(purchaseId)
.withResourceIdAsString(p.getDealNo()).withTransactionId(posted.getTransactionId()).build();
}
private static void addLine(final JsonArray lines, final Long glAccountId, final BigDecimal amount) {
if (amount != null && amount.signum() > 0) {
final JsonObject line = new JsonObject();
line.addProperty("glAccountId", glAccountId);
line.addProperty("amount", amount);
lines.add(line);
}
}
private Long glId(final String glCode) {
return this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(glCode).getId();
}
/** Investments are a Head Office function, so the settlement entry is booked there. */
private Long headOfficeId() {
return this.jdbcTemplate.queryForObject("SELECT id FROM m_office WHERE parent_id IS NULL ORDER BY id LIMIT 1", Long.class);
}
/** 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 faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
@@ -172,9 +261,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
}
final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate());
// Broken-period interest runs to the settlement date, when the bond leaves the seller (RBI 83, Jitendra Sir).
final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getSettlementDate());
final BigDecimal intRate = zeroIfNull(script.getIntRate());
// Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir.
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days);
p.setFaceValue(faceValue);
@@ -171,4 +171,5 @@
<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"/>
<include file="parts/0597_investment_purchase_settlement.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,74 @@
<?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">
<!--
Purchase settlement: a deal is saved as PENDING_SETTLEMENT and posted to GL only when it is settled on or
after its settlement date (settlement-date accounting). The journal entry transaction id and the settlement
day are kept on the deal. Settlement goes through maker-checker so the dealer cannot post their own deal.
-->
<changeSet author="cbs-anshul" id="0597-1-add-investment-purchase-settlement-columns">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_purchase" columnName="gl_transaction_id"/></not>
</preConditions>
<addColumn tableName="m_investment_purchase">
<column name="gl_transaction_id" type="VARCHAR(50)"/>
<column name="settled_on_date" type="DATE"/>
</addColumn>
</changeSet>
<changeSet author="cbs-anshul" id="0597-2-investment-purchase-settle-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code IN ('SETTLE_INVESTMENTPURCHASE', 'SETTLE_INVESTMENTPURCHASE_CHECKER')</sqlCheck>
</preConditions>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="SETTLE_INVESTMENTPURCHASE"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="SETTLE"/>
<column name="can_maker_checker" valueBoolean="true"/>
</insert>
<insert tableName="m_permission">
<column name="grouping" value="portfolio"/>
<column name="code" value="SETTLE_INVESTMENTPURCHASE_CHECKER"/>
<column name="entity_name" value="INVESTMENTPURCHASE"/>
<column name="action_name" value="SETTLE_CHECKER"/>
<column name="can_maker_checker" valueBoolean="false"/>
</insert>
</changeSet>
<!-- Same roles as the other investment screens; any other user holding the checker permission approves. -->
<changeSet author="cbs-anshul" id="0597-3-grant-investment-purchase-settle-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 ('SETTLE_INVESTMENTPURCHASE', 'SETTLE_INVESTMENTPURCHASE_CHECKER')
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>