feat(investment): sell, redemption, write-off, GOI transfer and premium amortisation
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Waiting to run
Fineract Liquibase Only mode - PostgreSQL / test (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Waiting to run
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Waiting to run

This commit is contained in:
2026-10-15 18:44:52 +05:30
parent 8ad90f0b00
commit 861e2e196e
38 changed files with 2871 additions and 4 deletions
@@ -159,6 +159,62 @@ public class CommandWrapperBuilder {
return this;
}
public CommandWrapperBuilder createInvestmentSale() {
this.actionName = "CREATE";
this.entityName = "INVESTMENTSALE";
this.entityId = null;
this.href = "/investment/sales";
return this;
}
public CommandWrapperBuilder settleInvestmentSale(final Long saleId) {
this.actionName = "SETTLE";
this.entityName = "INVESTMENTSALE";
this.entityId = saleId;
this.href = "/investment/sales/" + saleId + "/settle";
return this;
}
public CommandWrapperBuilder deleteInvestmentSale(final Long saleId) {
this.actionName = "DELETE";
this.entityName = "INVESTMENTSALE";
this.entityId = saleId;
this.href = "/investment/sales/" + saleId;
return this;
}
public CommandWrapperBuilder amortiseInvestmentPremium() {
this.actionName = "AMORTISE";
this.entityName = "INVESTMENTSALE";
this.entityId = null;
this.href = "/investment/amortisation";
return this;
}
public CommandWrapperBuilder writeOffInvestmentPurchase(final Long purchaseId) {
this.actionName = "WRITEOFF";
this.entityName = "INVESTMENTSALE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/write-off";
return this;
}
public CommandWrapperBuilder redeemInvestmentPurchase(final Long purchaseId) {
this.actionName = "REDEEM";
this.entityName = "INVESTMENTSALE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/redeem";
return this;
}
public CommandWrapperBuilder transferInvestmentSecurity(final Long purchaseId) {
this.actionName = "CREATE";
this.entityName = "INVESTMENTTRANSFER";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/transfer";
return this;
}
public CommandWrapperBuilder postInvestmentInterest(final Long purchaseId) {
this.actionName = "CREATE";
this.entityName = "INVESTMENTINTEREST";
@@ -0,0 +1,83 @@
/**
* 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.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.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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationDueData;
import org.apache.fineract.portfolio.investment.service.InvestmentAmortisationService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/amortisation")
@Component
@Tag(name = "Investment Premium Amortisation", description = "Premium of HTM holdings amortised straight-line to maturity every 31 March.")
@RequiredArgsConstructor
public class InvestmentAmortisationApiResource {
private final PlatformSecurityContext context;
private final InvestmentAmortisationService amortisationService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Amortisation schedule of a holding (06 Amortize Entry)")
public InvestmentAmortisationData schedule(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.amortisationService.schedule(purchaseId);
}
@GET
@Path("due")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Holdings due for amortisation on a 31 March (07 Amortize Yearly Process)", description = "fyEnd dd-MM-yyyy")
public List<InvestmentAmortisationDueData> due(@QueryParam("fyEnd") @Parameter(description = "dd-MM-yyyy") final String fyEnd) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.amortisationService.due(LocalDate.parse(fyEnd.trim(), DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Post amortisation for a 31 March", description = "With purchaseId: that holding (Amortize Entry); without: "
+ "every holding due (Amortize Yearly Process). One JV per holding: Dr 500604 / Cr premium GL.")
public CommandProcessingResult amortise(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().amortiseInvestmentPremium().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -35,6 +35,10 @@ public final class InvestmentPurchaseApiConstants {
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";
/** Every unit sold before maturity. */
public static final String STATUS_SOLD = "SOLD";
/** Repaid at maturity. */
public static final String STATUS_REDEEMED = "REDEEMED";
/** GLs the settlement entry debits besides the script's investment GL. */
public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606";
@@ -42,7 +42,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/purchases")
@@ -53,6 +55,7 @@ public class InvestmentPurchaseApiResource {
private final PlatformSecurityContext context;
private final InvestmentPurchaseReadPlatformService readPlatformService;
private final InvestmentSaleReadPlatformService saleReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@@ -121,6 +124,55 @@ public class InvestmentPurchaseApiResource {
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@GET
@Path("{purchaseId}/redemption-quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "What the redemption at maturity will post", description = "Face value back against the book value still "
+ "held; refused before maturity or while a sale is pending.")
public InvestmentSaleData redemptionQuote(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.saleReadPlatformService.quoteRedemption(purchaseId);
}
@POST
@Path("{purchaseId}/redeem")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Redeem at maturity", description = "On or after maturity, once every coupon is posted: posts the face value "
+ "received, dated the maturity date, and closes the holding.")
public CommandProcessingResult redeem(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().redeemInvestmentPurchase(purchaseId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/write-off")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Write off part of the book value", description = "Dr Loss on Investments / Cr Investment for the amount "
+ "given; the units stay held at the reduced book value.")
public CommandProcessingResult writeOff(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().writeOffInvestmentPurchase(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{purchaseId}/transfer")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "GOI Category Transfer", description = "Moves the holding to another security (scriptId) of the same coupon "
+ "rate, maturity and unit value; the book value moves between GLs only when the two scripts use different GLs.")
public CommandProcessingResult transfer(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().transferInvestmentSecurity(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON })
@@ -0,0 +1,70 @@
/**
* 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;
/** Sale (before maturity) and redemption (at maturity) of investment holdings. */
public final class InvestmentSaleApiConstants {
private InvestmentSaleApiConstants() {}
public static final String ENTITY = "INVESTMENTSALE";
public static final String RESOURCE = "investmentsale";
public static final String TYPE_SELL = "SELL";
public static final String TYPE_REDEEM = "REDEEM";
/** Part of the book value written off as a loss; the units stay held. */
public static final String TYPE_WRITEOFF = "WRITEOFF";
/** A year-end amortisation of premium (31 March); recorded like a sale so the book value comes down. */
public static final String TYPE_AMORTISE = "AMORTISE";
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
public static final String STATUS_SETTLED = "SETTLED";
/** Profit on sale / redemption (income) and loss on sale / redemption (expense). */
public static final String PROFIT_ON_SALE_GL_CODE = "400405";
public static final String LOSS_ON_SALE_GL_CODE = "500605";
/** Premium still on the books at maturity is written off to the amortisation expense. */
public static final String PREMIUM_AMORTISATION_GL_CODE = "500604";
/** Premium paid over face value, held here until amortised (Jitendra Sir). */
public static final String PREMIUM_ON_INVESTMENTS_GL_CODE = "100707";
public static final String PURCHASE_ID = "purchaseId";
public static final String PARTY_ID = "partyId";
public static final String DEAL_DATE = "dealDate";
public static final String DEAL_TIME = "dealTime";
public static final String SETTLEMENT_TYPE = "settlementType";
public static final String UNITS = "units";
public static final String SALE_RATE = "saleRate";
public static final String BROKERAGE = "brokerage";
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String SANCTION_AUTHORITY = "sanctionAuthority";
public static final String REMARKS = "remarks";
public static final String AMOUNT = "amount";
public static final String FY_END = "fyEnd";
public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> SELL_PARAMS = Set.of(PURCHASE_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, UNITS, SALE_RATE,
BROKERAGE, BANK_GL_ACCOUNT_ID, SANCTION_AUTHORITY, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> REDEEM_PARAMS = Set.of(BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> WRITEOFF_PARAMS = Set.of(AMOUNT, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT);
}
@@ -0,0 +1,116 @@
/**
* 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.math.BigDecimal;
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.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/sales")
@Component
@Tag(name = "Investment Sale", description = "Sale of holdings before maturity (all or part of the units), settled on the settlement date.")
@RequiredArgsConstructor
public class InvestmentSaleApiResource {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final PlatformSecurityContext context;
private final InvestmentSaleReadPlatformService readPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List sales and redemptions, newest first")
public List<InvestmentSaleData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
return this.readPlatformService.retrieveAll();
}
@GET
@Path("quote")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Work out a sale before saving it", description = "Price, interest from the buyer, cost, profit or loss "
+ "and the net amount for the units, rate and deal date given (dd-MM-yyyy).")
public InvestmentSaleData quote(@QueryParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("settlementType") @Parameter(description = "T+0 / T+1 / T+2") final String settlementType,
@QueryParam("units") @Parameter(description = "units") final BigDecimal units,
@QueryParam("saleRate") @Parameter(description = "saleRate") final BigDecimal saleRate,
@QueryParam("brokerage") @Parameter(description = "brokerage") final BigDecimal brokerage,
@QueryParam("bankGlAccountId") @Parameter(description = "bankGlAccountId") final Long bankGlAccountId) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentSaleApiConstants.ENTITY);
final LocalDate deal = LocalDate.parse(dealDate.trim(), DATE);
return this.readPlatformService.quoteSale(purchaseId, deal, StringUtils.defaultIfBlank(settlementType, "T+0"), units, saleRate,
brokerage, bankGlAccountId);
}
@POST
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Save a sale", description = "Saved pending; nothing reaches the GL until it is settled.")
public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().createInvestmentSale().withJson(apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST
@Path("{saleId}/settle")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Settle a sale", description = "On or after the settlement date: posts the sale to GL dated the settlement "
+ "date and reduces the units held.")
public CommandProcessingResult settle(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentSale(saleId)
.withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@DELETE
@Path("{saleId}")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Drop a pending sale")
public CommandProcessingResult delete(@PathParam("saleId") @Parameter(description = "saleId") final Long saleId) {
final CommandWrapper request = new CommandWrapperBuilder().deleteInvestmentSale(saleId).build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
}
@@ -0,0 +1,50 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.api;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentSecurityTransferData;
import org.apache.fineract.portfolio.investment.service.InvestmentSecurityTransferService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/transfers")
@Component
@Tag(name = "Investment Security Transfer", description = "GOI Category Transfer: a holding moved to another security of the same terms.")
@RequiredArgsConstructor
public class InvestmentTransferApiResource {
private final PlatformSecurityContext context;
private final InvestmentSecurityTransferService transferService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List security transfers, newest first")
public List<InvestmentSecurityTransferData> retrieveAll() {
this.context.authenticatedUser().validateHasReadPermission("INVESTMENTTRANSFER");
return this.transferService.retrieveAll();
}
}
@@ -0,0 +1,64 @@
/**
* 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.util.List;
import lombok.Builder;
import lombok.Getter;
/** Premium amortisation schedule of an HTM holding: one line per 31 March to maturity, then the rest at maturity. */
@Getter
@Builder
public class InvestmentAmortisationData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final String category;
private final String settlementDate;
private final String maturityDate;
private final BigDecimal faceValue;
private final BigDecimal premium;
private final BigDecimal unamortisedPremium;
private final BigDecimal bookValue;
private final String premiumGlCode;
private final List<Row> rows;
@Getter
@Builder
public static class Row {
/** YEAR (31 March) or MATURITY (the remainder, written off on redemption). */
private final String kind;
private final String fyEnd;
private final String fromDate;
private final String toDate;
private final long days;
private final BigDecimal amount;
private final BigDecimal premiumAfter;
private final boolean posted;
private final String voucherNo;
private final String glTransactionId;
/** Next line to post and its 31 March has come. */
private final boolean postable;
}
}
@@ -0,0 +1,40 @@
/**
* 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 lombok.Builder;
import lombok.Getter;
/** A holding's premium amortisation due for a 31 March (Amortize Yearly Process). */
@Getter
@Builder
public class InvestmentAmortisationDueData {
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final String fyEnd;
private final String fromDate;
private final long days;
private final BigDecimal unamortisedPremium;
private final BigDecimal amount;
}
@@ -0,0 +1,102 @@
/**
* 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.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
/** A sale or redemption of a holding — also the shape of a quote before it is saved. */
@Getter
@Builder
public class InvestmentSaleData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String bondType;
private final String scriptCode;
private final String scriptDesc;
private final String saleType;
private final Long partyId;
private final String partyName;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final BigDecimal units;
private final BigDecimal faceValue;
private final BigDecimal saleRate;
private final BigDecimal consideration;
private final Integer accruedDays;
private final BigDecimal accruedInterest;
private final BigDecimal receivableCleared;
/** Interest from the buyer not yet booked: accrued interest less the receivable cleared. */
private final BigDecimal interestIncome;
private final BigDecimal brokerage;
private final BigDecimal bookValueSold;
private final BigDecimal premiumCleared;
private final BigDecimal profitLoss;
private final BigDecimal netAmount;
private final Long bankGlAccountId;
private final String bankGlCode;
private final String bankGlName;
private final String investmentGlCode;
/** GL the premium share is credited to: 100707, or the investment GL for deals settled before it was used. */
private final String premiumGlCode;
private final String sanctionAuthority;
private final String remarks;
private final String status;
private final String glTransactionId;
private final String voucherNo;
private final String settledOnDate;
public static InvestmentSaleData from(final InvestmentSale s) {
return builder().id(s.getId()).purchaseId(s.getPurchase().getId()).dealNo(s.getPurchase().getDealNo())
.certNo(s.getPurchase().getCertNo()).bondType(s.getPurchase().getBondType())
.scriptCode(s.getPurchase().getScript().getScriptCode()).scriptDesc(s.getPurchase().getScript().getScriptDesc())
.saleType(s.getSaleType()).partyId(s.getParty() == null ? null : s.getParty().getId())
.partyName(s.getParty() == null ? null : s.getParty().getPartyName()).dealDate(format(s.getDealDate()))
.dealTime(s.getDealTime()).settlementType(s.getSettlementType()).settlementDate(format(s.getSettlementDate()))
.units(s.getUnits()).faceValue(s.getFaceValue()).saleRate(s.getSaleRate()).consideration(s.getConsideration())
.accruedDays(s.getAccruedDays()).accruedInterest(s.getAccruedInterest()).receivableCleared(s.getReceivableCleared())
.interestIncome(zero(s.getAccruedInterest()).subtract(zero(s.getReceivableCleared()))).brokerage(s.getBrokerage())
.bookValueSold(s.getBookValueSold()).premiumCleared(s.getPremiumCleared()).profitLoss(s.getProfitLoss()).netAmount(s.getNetAmount())
.bankGlAccountId(s.getBankGlAccount().getId()).bankGlCode(s.getBankGlAccount().getGlCode())
.bankGlName(s.getBankGlAccount().getName()).investmentGlCode(s.getPurchase().getInvestmentGlAccount().getGlCode())
.premiumGlCode(s.getPurchase().getPremiumGlAccount() != null ? s.getPurchase().getPremiumGlAccount().getGlCode()
: s.getPurchase().getInvestmentGlAccount().getGlCode())
.sanctionAuthority(s.getSanctionAuthority()).remarks(s.getRemarks()).status(s.getStatus())
.glTransactionId(s.getGlTransactionId()).voucherNo(s.getVoucherNo()).settledOnDate(format(s.getSettledOnDate())).build();
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE_FORMAT);
}
}
@@ -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.data;
import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import lombok.Builder;
import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer;
/** A GOI Category Transfer of a holding, for the transaction log. */
@Getter
@Builder
public class InvestmentSecurityTransferData {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String bondType;
private final String fromScriptCode;
private final String fromScriptDesc;
private final String toScriptCode;
private final String toScriptDesc;
private final String fromGlCode;
private final String toGlCode;
private final String toGlName;
private final String transferDate;
private final BigDecimal units;
private final BigDecimal bookValue;
private final String remarks;
private final String glTransactionId;
private final String voucherNo;
public static InvestmentSecurityTransferData from(final InvestmentSecurityTransfer t) {
return builder().id(t.getId()).purchaseId(t.getPurchase().getId()).dealNo(t.getPurchase().getDealNo())
.certNo(t.getPurchase().getCertNo()).bondType(t.getPurchase().getBondType()).fromScriptCode(t.getFromScript().getScriptCode())
.fromScriptDesc(t.getFromScript().getScriptDesc()).toScriptCode(t.getToScript().getScriptCode())
.toScriptDesc(t.getToScript().getScriptDesc()).fromGlCode(t.getFromGlAccount().getGlCode())
.toGlCode(t.getToGlAccount().getGlCode()).toGlName(t.getToGlAccount().getName())
.transferDate(t.getTransferDate().format(DATE_FORMAT)).units(t.getUnits()).bookValue(t.getBookValue())
.remarks(t.getRemarks()).glTransactionId(t.getGlTransactionId()).voucherNo(t.getVoucherNo()).build();
}
}
@@ -143,6 +143,11 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<L
@JoinColumn(name = "cr_gl_account_id", nullable = false)
private GLAccount crGlAccount;
/** GL holding the premium paid over face value (100707) until it is amortised; empty when it sits in the investment GL. */
@ManyToOne
@JoinColumn(name = "premium_gl_account_id")
private GLAccount premiumGlAccount;
@Column(name = "other_details", length = 4000)
private String otherDetails;
@@ -0,0 +1,132 @@
/**
* 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.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A sale of some or all units of a holding before maturity (SELL), or its repayment at maturity (REDEEM). */
@Entity
@Table(name = "m_investment_sale")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentSale extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
/** SELL, REDEEM, WRITEOFF or AMORTISE. */
@Column(name = "sale_type", nullable = false, length = 10)
private String saleType;
/** Buyer of a sale; empty for a redemption. */
@ManyToOne
@JoinColumn(name = "party_id")
private InvestmentParty party;
@Column(name = "deal_date", nullable = false)
private LocalDate dealDate;
@Column(name = "deal_time", length = 8)
private String dealTime;
@Column(name = "settlement_type", length = 5)
private String settlementType;
@Column(name = "settlement_date", nullable = false)
private LocalDate settlementDate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
@Column(name = "face_value", scale = 6, precision = 19, nullable = false)
private BigDecimal faceValue;
/** Price per ₹100 of face value; 100 for a redemption. */
@Column(name = "sale_rate", scale = 6, precision = 19)
private BigDecimal saleRate;
/** Price received for the units, without interest. */
@Column(name = "consideration", scale = 6, precision = 19, nullable = false)
private BigDecimal consideration;
@Column(name = "accrued_days")
private Integer accruedDays;
/** Interest the buyer pays us for the days since the last coupon. */
@Column(name = "accrued_interest", scale = 6, precision = 19)
private BigDecimal accruedInterest;
/** Part of that interest already booked by a closing provision, cleared from interest receivable. */
@Column(name = "receivable_cleared", scale = 6, precision = 19)
private BigDecimal receivableCleared;
@Column(name = "brokerage", scale = 6, precision = 19)
private BigDecimal brokerage;
/** Cost of the units sold, taken off the investment GL. */
@Column(name = "book_value_sold", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValueSold;
/** Part of the book value taken off that was unamortised premium (credited to the premium GL). */
@Column(name = "premium_cleared", scale = 6, precision = 19)
private BigDecimal premiumCleared;
/** Consideration less cost: positive is profit, negative is loss. */
@Column(name = "profit_loss", scale = 6, precision = 19, nullable = false)
private BigDecimal profitLoss;
/** Money into the bank: consideration plus interest, less brokerage. */
@Column(name = "net_amount", scale = 6, precision = 19, nullable = false)
private BigDecimal netAmount;
@ManyToOne
@JoinColumn(name = "bank_gl_account_id", nullable = false)
private GLAccount bankGlAccount;
@Column(name = "sanction_authority", length = 100)
private String sanctionAuthority;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
@Column(name = "settled_on_date")
private LocalDate settledOnDate;
}
@@ -0,0 +1,27 @@
/**
* 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.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentSaleRepository extends JpaRepository<InvestmentSale, Long> {
List<InvestmentSale> findByPurchaseIdOrderByIdAsc(Long purchaseId);
}
@@ -18,6 +18,10 @@
*/
package org.apache.fineract.portfolio.investment.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {}
public interface InvestmentScriptRepository extends JpaRepository<InvestmentScript, Long> {
List<InvestmentScript> findByIsinIgnoreCase(String isin);
}
@@ -0,0 +1,80 @@
/**
* 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.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom;
/** A holding moved from one government security script to another of the same terms (GOI Category Transfer). */
@Entity
@Table(name = "m_investment_security_transfer")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentSecurityTransfer extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
@ManyToOne
@JoinColumn(name = "from_script_id", nullable = false)
private InvestmentScript fromScript;
@ManyToOne
@JoinColumn(name = "to_script_id", nullable = false)
private InvestmentScript toScript;
@ManyToOne
@JoinColumn(name = "from_gl_account_id", nullable = false)
private GLAccount fromGlAccount;
@ManyToOne
@JoinColumn(name = "to_gl_account_id", nullable = false)
private GLAccount toGlAccount;
@Column(name = "transfer_date", nullable = false)
private LocalDate transferDate;
@Column(name = "units", scale = 6, precision = 19, nullable = false)
private BigDecimal units;
/** Book value carried over; moved between GLs only when the two scripts map to different GLs. */
@Column(name = "book_value", scale = 6, precision = 19, nullable = false)
private BigDecimal bookValue;
@Column(name = "remarks", length = 500)
private String remarks;
@Column(name = "gl_transaction_id", length = 50)
private String glTransactionId;
@Column(name = "voucher_no", length = 30)
private String voucherNo;
}
@@ -0,0 +1,23 @@
/**
* 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 org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentSecurityTransferRepository extends JpaRepository<InvestmentSecurityTransfer, Long> {}
@@ -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.InvestmentAmortisationService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "AMORTISE")
@RequiredArgsConstructor
public class AmortiseInvestmentPremiumCommandHandler implements NewCommandSourceHandler {
private final InvestmentAmortisationService amortisationService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.amortisationService.amortise(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.InvestmentSaleWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "CREATE")
@RequiredArgsConstructor
public class CreateInvestmentSaleCommandHandler implements NewCommandSourceHandler {
private final InvestmentSaleWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.createSale(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.InvestmentSaleWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "DELETE")
@RequiredArgsConstructor
public class DeleteInvestmentSaleCommandHandler implements NewCommandSourceHandler {
private final InvestmentSaleWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.deleteSale(command.entityId());
}
}
@@ -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.InvestmentSaleWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "REDEEM")
@RequiredArgsConstructor
public class RedeemInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentSaleWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.redeem(command.entityId(), 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.InvestmentSaleWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "SETTLE")
@RequiredArgsConstructor
public class SettleInvestmentSaleCommandHandler implements NewCommandSourceHandler {
private final InvestmentSaleWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.settleSale(command.entityId(), 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.InvestmentSecurityTransferService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTTRANSFER", action = "CREATE")
@RequiredArgsConstructor
public class TransferInvestmentSecurityCommandHandler implements NewCommandSourceHandler {
private final InvestmentSecurityTransferService transferService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.transferService.transfer(command.entityId(), 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.InvestmentSaleWritePlatformService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTSALE", action = "WRITEOFF")
@RequiredArgsConstructor
public class WriteOffInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentSaleWritePlatformService writePlatformService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.writePlatformService.writeOff(command.entityId(), command);
}
}
@@ -0,0 +1,126 @@
/**
* 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.InvestmentSaleApiConstants.AMOUNT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BANK_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PURCHASE_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.REDEEM_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.REMARKS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.RESOURCE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SALE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SANCTION_AUTHORITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SELL_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.UNITS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.WRITEOFF_PARAMS;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
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.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.springframework.stereotype.Component;
/** Validates a sale entry and a redemption request. */
@Component
@RequiredArgsConstructor
public final class InvestmentSaleCommandFromApiJsonDeserializer {
private final FromJsonHelper fromApiJsonHelper;
public void validateForSell(final String json) {
final JsonElement element = parse(json, SELL_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
v.reset().parameter(PURCHASE_ID).value(this.fromApiJsonHelper.extractLongNamed(PURCHASE_ID, element)).notNull()
.longGreaterThanZero();
v.reset().parameter(PARTY_ID).value(this.fromApiJsonHelper.extractLongNamed(PARTY_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(BANK_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(BANK_GL_ACCOUNT_ID, element)).notNull()
.longGreaterThanZero();
v.reset().parameter(DEAL_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DEAL_DATE, element)).notNull();
final String settlementType = this.fromApiJsonHelper.extractStringNamed(SETTLEMENT_TYPE, element);
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType).notBlank();
if (StringUtils.isNotBlank(settlementType)) {
v.reset().parameter(SETTLEMENT_TYPE).value(settlementType)
.isOneOfTheseValues(InvestmentPurchaseApiConstants.SETTLEMENT_TYPES.toArray());
}
v.reset().parameter(UNITS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(UNITS, element)).notNull()
.positiveAmount();
v.reset().parameter(SALE_RATE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(SALE_RATE, element)).notNull()
.positiveAmount();
v.reset().parameter(BROKERAGE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(BROKERAGE, element)).ignoreIfNull()
.zeroOrPositiveAmount();
v.reset().parameter(SANCTION_AUTHORITY).value(this.fromApiJsonHelper.extractStringNamed(SANCTION_AUTHORITY, element))
.ignoreIfNull().notExceedingLengthOf(100);
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).ignoreIfNull()
.notExceedingLengthOf(500);
throwIfAny(errors);
}
public void validateForRedeem(final String json) {
final JsonElement element = parse(json, REDEEM_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
v.reset().parameter(BANK_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(BANK_GL_ACCOUNT_ID, element)).ignoreIfNull()
.longGreaterThanZero();
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).ignoreIfNull()
.notExceedingLengthOf(500);
throwIfAny(errors);
}
public void validateForWriteOff(final String json) {
final JsonElement element = parse(json, WRITEOFF_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
v.reset().parameter(AMOUNT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AMOUNT, element)).notNull()
.positiveAmount();
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).notBlank()
.notExceedingLengthOf(500);
throwIfAny(errors);
}
private JsonElement parse(final String json, final Set<String> params) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, params);
return this.fromApiJsonHelper.parse(json);
}
private static void throwIfAny(final List<ApiParameterError> errors) {
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -0,0 +1,292 @@
/**
* 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.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.AMORTISE_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.FY_END;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PREMIUM_AMORTISATION_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PURCHASE_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_SETTLED;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_AMORTISE;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.Month;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
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.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationData;
import org.apache.fineract.portfolio.investment.data.InvestmentAmortisationDueData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Premium amortisation of HTM holdings: the premium paid over face value is written off straight-line, day by day, over
* the period remaining to maturity (RBI; method and 31 March timing confirmed with Jitendra Sir). Each 31 March posts the
* share for the days since the last amortisation (or settlement): unamortised premium x days to 31 March / days to
* maturity — so a part sale, which takes its share of premium with it, is followed automatically. The remainder after
* the last 31 March is written off with the redemption. Entry: Dr 500604 Amortisation of Premium / Cr Premium GL.
*/
@Service
@RequiredArgsConstructor
public class InvestmentAmortisationService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final String HTM_PREFIX = "HTM";
private final FromJsonHelper fromApiJsonHelper;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentSaleQuoter quoter;
private final InvestmentJournalPoster journalPoster;
@Transactional(readOnly = true)
public InvestmentAmortisationData schedule(final Long purchaseId) {
final InvestmentPurchase p = purchase(purchaseId);
final List<InvestmentSale> posted = amortisations(p);
final LocalDate today = DateUtils.getBusinessLocalDate();
final List<InvestmentAmortisationData.Row> rows = new ArrayList<>();
BigDecimal premium = this.quoter.unamortisedPremium(p);
LocalDate start = p.getSettlementDate();
boolean nextFound = false;
for (final LocalDate fyEnd : yearEnds(p)) {
final Optional<InvestmentSale> done = posted.stream().filter(x -> x.getDealDate().equals(fyEnd)).findFirst();
final long days = ChronoUnit.DAYS.between(start, fyEnd);
if (done.isPresent()) {
rows.add(row("YEAR", fyEnd, start, days, done.get().getBookValueSold(), null, true, done.get(), false));
} else {
final BigDecimal amount = share(premium, start, fyEnd, p.getMaturityDate());
premium = premium.subtract(amount);
final boolean postable = STATUS_ACTIVE.equals(p.getStatus()) && !nextFound && !fyEnd.isAfter(today);
nextFound = true;
rows.add(row("YEAR", fyEnd, start, days, amount, premium, false, null, postable));
}
start = fyEnd;
}
if (premium.signum() > 0 && STATUS_ACTIVE.equals(p.getStatus())) {
rows.add(row("MATURITY", p.getMaturityDate(), start, ChronoUnit.DAYS.between(start, p.getMaturityDate()), premium,
BigDecimal.ZERO, false, null, false));
}
return InvestmentAmortisationData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).category(p.getCategory())
.settlementDate(p.getSettlementDate().format(DATE)).maturityDate(p.getMaturityDate().format(DATE))
.faceValue(p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit()))
.premium(p.getPremiumDiscount() == null ? BigDecimal.ZERO : p.getPremiumDiscount().max(BigDecimal.ZERO))
.unamortisedPremium(this.quoter.unamortisedPremium(p)).bookValue(this.quoter.remainingBook(p))
.premiumGlCode(p.getPremiumGlAccount() != null ? p.getPremiumGlAccount().getGlCode() : p.getInvestmentGlAccount().getGlCode())
.rows(rows).build();
}
/** HTM holdings with premium whose next amortisation falls on {@code fyEnd} (Amortize Yearly Process list). */
@Transactional(readOnly = true)
public List<InvestmentAmortisationDueData> due(final LocalDate fyEnd) {
final List<InvestmentAmortisationDueData> due = new ArrayList<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
if (eligible(p) && fyEnd.equals(nextYearEnd(p))) {
final LocalDate start = periodStart(p);
final BigDecimal premium = this.quoter.unamortisedPremium(p);
due.add(InvestmentAmortisationDueData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).fyEnd(fyEnd.format(DATE))
.fromDate(start.format(DATE)).days(ChronoUnit.DAYS.between(start, fyEnd)).unamortisedPremium(premium)
.amount(share(premium, start, fyEnd, p.getMaturityDate())).build());
}
}
return due;
}
/**
* Posts the amortisation for {@code fyEnd} (a 31 March on or before today): for one holding when purchaseId is
* given (Amortize Entry), else for every holding due that year (Amortize Yearly Process). One JV per holding.
*/
@Transactional
public CommandProcessingResult amortise(final JsonCommand command) {
validate(command.json());
final LocalDate fyEnd = command.localDateValueOfParameterNamed(FY_END);
if (fyEnd.getMonth() != Month.MARCH || fyEnd.getDayOfMonth() != 31) {
throw InvestmentSaleQuoter.rule("amortise.not.yearend", "Amortisation is done as on 31 March");
}
if (fyEnd.isAfter(DateUtils.getBusinessLocalDate())) {
throw InvestmentSaleQuoter.rule("amortise.future", "Amortisation for " + fyEnd.format(DATE) + " can be done on or after that date");
}
final Long purchaseId = command.longValueOfParameterNamed(PURCHASE_ID);
final List<InvestmentPurchase> targets = new ArrayList<>();
if (purchaseId != null) {
final InvestmentPurchase p = purchase(purchaseId);
if (!eligible(p)) {
throw InvestmentSaleQuoter.rule("amortise.not.eligible",
"Deal " + p.getDealNo() + " has no premium left to amortise (only active HTM holdings bought above face value)");
}
final LocalDate next = nextYearEnd(p);
if (!fyEnd.equals(next)) {
throw InvestmentSaleQuoter.rule("amortise.out.of.order", next == null ? "Deal " + p.getDealNo() + " has no 31 March to amortise"
: "Amortise deal " + p.getDealNo() + " year by year: the next is " + next.format(DATE));
}
targets.add(p);
} else {
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
if (eligible(p) && fyEnd.equals(nextYearEnd(p))) {
targets.add(p);
}
}
if (targets.isEmpty()) {
throw InvestmentSaleQuoter.rule("amortise.nothing.due", "No holding has premium amortisation due for " + fyEnd.format(DATE));
}
}
final List<String> vouchers = new ArrayList<>();
Long lastId = null;
for (final InvestmentPurchase p : targets) {
final InvestmentSale s = post(p, fyEnd);
vouchers.add(s.getVoucherNo());
lastId = s.getId();
}
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(lastId)
.withResourceIdAsString(String.join(", ", vouchers)).build();
}
private InvestmentSale post(final InvestmentPurchase p, final LocalDate fyEnd) {
final LocalDate start = periodStart(p);
final BigDecimal amount = share(this.quoter.unamortisedPremium(p), start, fyEnd, p.getMaturityDate());
final String fy = (fyEnd.getYear() - 1) + "-" + String.format("%02d", fyEnd.getYear() % 100);
final CommandProcessingResult posted = this.journalPoster.post(fyEnd, p.getDealNo(),
"Premium amortisation FY " + fy + ": deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " "
+ p.getScript().getScriptDesc() + " (" + ChronoUnit.DAYS.between(start, fyEnd) + " days)",
new InvestmentJournalPoster.Lines().debit(this.journalPoster.glId(PREMIUM_AMORTISATION_GL_CODE), amount)
.credit(InvestmentSaleQuoter.premiumGlId(p), amount));
final InvestmentSale s = new InvestmentSale();
s.setPurchase(p);
s.setSaleType(TYPE_AMORTISE);
s.setDealDate(fyEnd);
s.setSettlementDate(fyEnd);
s.setUnits(BigDecimal.ZERO);
s.setFaceValue(BigDecimal.ZERO);
s.setConsideration(BigDecimal.ZERO);
s.setAccruedDays((int) ChronoUnit.DAYS.between(start, fyEnd));
s.setAccruedInterest(BigDecimal.ZERO);
s.setReceivableCleared(BigDecimal.ZERO);
s.setBrokerage(BigDecimal.ZERO);
s.setBookValueSold(amount);
s.setPremiumCleared(amount);
s.setProfitLoss(amount.negate());
s.setNetAmount(BigDecimal.ZERO);
s.setBankGlAccount(p.getCrGlAccount());
s.setRemarks("Premium amortisation FY " + fy);
s.setStatus(STATUS_SETTLED);
s.setGlTransactionId(posted.getTransactionId());
final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo");
s.setVoucherNo(voucherNo == null ? null : voucherNo.toString());
s.setSettledOnDate(DateUtils.getBusinessLocalDate());
return this.saleRepository.saveAndFlush(s);
}
/** Straight-line by days: the share of the unamortised premium for start..yearEnd out of start..maturity. */
static BigDecimal share(final BigDecimal premium, final LocalDate start, final LocalDate yearEnd, final LocalDate maturity) {
final long total = ChronoUnit.DAYS.between(start, maturity);
if (premium.signum() <= 0 || total <= 0) {
return BigDecimal.ZERO;
}
return premium.multiply(BigDecimal.valueOf(ChronoUnit.DAYS.between(start, yearEnd))).divide(BigDecimal.valueOf(total), 2,
RoundingMode.HALF_UP);
}
private boolean eligible(final InvestmentPurchase p) {
return STATUS_ACTIVE.equals(p.getStatus()) && p.getCategory() != null && p.getCategory().startsWith(HTM_PREFIX)
&& this.quoter.unamortisedPremium(p).signum() > 0;
}
/** The first 31 March after the last amortisation (or the settlement) that is still before maturity. */
private LocalDate nextYearEnd(final InvestmentPurchase p) {
final LocalDate start = periodStart(p);
return yearEnds(p).stream().filter(d -> d.isAfter(start)).findFirst().orElse(null);
}
private LocalDate periodStart(final InvestmentPurchase p) {
return amortisations(p).stream().map(InvestmentSale::getDealDate).max(LocalDate::compareTo).orElse(p.getSettlementDate());
}
/** 31 March dates after the settlement and before maturity. */
private static List<LocalDate> yearEnds(final InvestmentPurchase p) {
final List<LocalDate> ends = new ArrayList<>();
for (int y = p.getSettlementDate().getYear(); y <= p.getMaturityDate().getYear(); y++) {
final LocalDate d = LocalDate.of(y, Month.MARCH, 31);
if (d.isAfter(p.getSettlementDate()) && d.isBefore(p.getMaturityDate())) {
ends.add(d);
}
}
return ends;
}
private List<InvestmentSale> amortisations(final InvestmentPurchase p) {
return this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId()).stream()
.filter(s -> TYPE_AMORTISE.equals(s.getSaleType()) && STATUS_SETTLED.equals(s.getStatus())).toList();
}
private static InvestmentAmortisationData.Row row(final String kind, final LocalDate end, final LocalDate start, final long days,
final BigDecimal amount, final BigDecimal after, final boolean posted, final InvestmentSale done, final boolean postable) {
return InvestmentAmortisationData.Row.builder().kind(kind).fyEnd(end.format(DATE)).fromDate(start.format(DATE))
.toDate(end.format(DATE)).days(days).amount(amount).premiumAfter(after).posted(posted)
.voucherNo(done == null ? null : done.getVoucherNo()).glTransactionId(done == null ? null : done.getGlTransactionId())
.postable(postable).build();
}
private InvestmentPurchase purchase(final Long purchaseId) {
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
}
private void validate(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, AMORTISE_PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource("investmentsale");
v.reset().parameter(FY_END).value(this.fromApiJsonHelper.extractLocalDateNamed(FY_END, element)).notNull();
v.reset().parameter(PURCHASE_ID).value(this.fromApiJsonHelper.extractLongNamed(PURCHASE_ID, element)).ignoreIfNull()
.longGreaterThanZero();
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
}
@@ -211,6 +211,9 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
private void applyScript(final InvestmentScript s, final JsonCommand command) {
setIfPresent(command, SLR_TYPE, s::setSlrType);
setIfPresent(command, SCRIPT_DESC, s::setScriptDesc);
if (command.parameterExists(ISIN)) {
requireUniqueIsin(s, command.stringValueOfParameterNamed(ISIN));
}
setIfPresent(command, ISIN, s::setIsin);
setIfPresent(command, COMPANY_NAME, s::setCompanyName);
setIfPresent(command, ADDRESS1, s::setAddress1);
@@ -242,6 +245,19 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
}
}
/** An ISIN identifies one security, so two scripts cannot carry it; checked when it is entered or changed. */
private void requireUniqueIsin(final InvestmentScript s, final String isin) {
if (StringUtils.isBlank(isin) || isin.trim().equalsIgnoreCase(StringUtils.trimToEmpty(s.getIsin()))) {
return;
}
this.scriptRepository.findByIsinIgnoreCase(isin.trim()).stream().filter(other -> !other.getId().equals(s.getId())).findFirst()
.ifPresent(other -> {
throw new GeneralPlatformDomainRuleException("error.msg.investmentscript.isin.duplicate",
"ISIN " + isin.trim() + " is already used by script " + other.getScriptCode() + " " + other.getScriptDesc(),
isin.trim());
});
}
private InvestmentScript saveScript(final InvestmentScript script) {
try {
return this.scriptRepository.saveAndFlush(script);
@@ -87,8 +87,12 @@ public class InvestmentPurchaseReadPlatformService {
nextFound = true;
}
rows.add(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)).posted(done.isPresent())
.toDate(format(r.toDate())).days(r.days())
// A posted line shows what was posted, even after a part sale has changed the face value held.
.grossInterest(done.map(InvestmentInterestPosting::getGrossInterest).orElse(r.grossInterest()))
.alreadyBooked(done.map(InvestmentInterestPosting::getAlreadyBooked).orElse(r.alreadyBooked()))
.amount(done.map(InvestmentInterestPosting::getAmount).orElse(r.amount())).due(!r.date().isAfter(asOn))
.posted(done.isPresent())
.voucherNo(done.map(InvestmentInterestPosting::getVoucherNo).orElse(null))
.glTransactionId(done.map(InvestmentInterestPosting::getGlTransactionId).orElse(null))
.postedOnDate(done.map(x -> format(x.getPostedOnDate())).orElse(null)).postable(isNext && !r.date().isAfter(today))
@@ -193,8 +193,16 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
+ p.getSettlementDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")));
}
// Premium over face value goes to its own GL (100707) and is amortised to maturity; the investment GL takes the rest.
final BigDecimal premium = p.getPremiumDiscount() != null && p.getPremiumDiscount().signum() > 0 ? p.getPremiumDiscount()
: BigDecimal.ZERO;
if (premium.signum() > 0) {
p.setPremiumGlAccount(this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(
org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PREMIUM_ON_INVESTMENTS_GL_CODE));
}
final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines()
.debit(p.getInvestmentGlAccount().getId(), p.getBookValue())
.debit(p.getInvestmentGlAccount().getId(), p.getBookValue().subtract(premium))
.debit(premium.signum() > 0 ? p.getPremiumGlAccount().getId() : null, premium)
.debit(this.journalPoster.glId(BROKEN_PERIOD_INTEREST_GL_CODE), p.getAccruedInterest())
.debit(this.journalPoster.glId(STAMP_DUTY_GL_CODE), p.getStampDuty())
.debit(this.journalPoster.glId(BROKERAGE_GL_CODE), p.getBrokerage())
@@ -0,0 +1,294 @@
/**
* 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.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_PENDING_SETTLEMENT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_SETTLED;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_REDEEM;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_SELL;
import java.math.BigDecimal;
import java.math.RoundingMode;
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.accounting.glaccount.domain.GLAccount;
import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper;
import org.apache.fineract.accounting.glaccount.domain.GLAccountType;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPosting;
import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPostingRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.springframework.stereotype.Component;
/**
* Works out a sale or a redemption of a holding — the same figures for the screen's preview, for saving and for
* posting, so the voucher always matches what was shown.
* <ul>
* <li>Sale: price = face value sold × rate / 100; cost = the units' share of the book value; profit or loss = price −
* cost. The voucher carries the principal only (Jitendra Sir): the interest since the last coupon that the buyer pays is
* worked out as a hint — the part a closing provision already booked as receivable and the rest as income — for a manual
* entry. No short sale: only units still held and not in another pending sale.</li>
* <li>Redemption: the face value comes back at maturity against the book value still held; a discount left is profit,
* a premium left is written off.</li>
* </ul>
*/
@Component
@RequiredArgsConstructor
public class InvestmentSaleQuoter {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final String HTM_PREFIX = "HTM";
private final InvestmentSaleRepository saleRepository;
private final InvestmentInterestPostingRepository postingRepository;
private final InvestmentPurchaseReadPlatformService purchaseReadPlatformService;
private final GLAccountRepositoryWrapper glAccountRepository;
/** Inputs of a sale entry. */
public record SellRequest(InvestmentParty party, LocalDate dealDate, String dealTime, String settlementType, BigDecimal units,
BigDecimal saleRate, BigDecimal brokerage, Long bankGlAccountId, String sanctionAuthority, String remarks) {
}
/**
* A pending sale with its figures worked out; {@code ignoreSaleId} leaves the sale being settled out of the units
* check. A {@code preview} skips the checks on what the user has not typed yet (buyer, HTM sanction).
*/
public InvestmentSale quoteSale(final InvestmentPurchase p, final SellRequest r, final Long ignoreSaleId, final boolean preview) {
requireActiveDebtHolding(p);
if (r.party() != null && !r.party().isActive()) {
throw rule("party.inactive", "Party " + r.party().getPartyCode() + " is inactive");
}
if (r.dealDate().isBefore(p.getSettlementDate())) {
throw rule("deal.before.purchase", "A sale cannot be dated before the purchase settled on " + p.getSettlementDate().format(DATE));
}
if (r.dealDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("deal.future", "The sale deal date cannot be after today");
}
final LocalDate settlementDate = r.dealDate().plusDays(Long.parseLong(r.settlementType().substring(2)));
if (!settlementDate.isBefore(p.getMaturityDate())) {
throw rule("sell.at.maturity", "The security matures on " + p.getMaturityDate().format(DATE)
+ "; on or after maturity it is redeemed, not sold");
}
if (!preview && p.getCategory() != null && p.getCategory().startsWith(HTM_PREFIX)
&& (StringUtils.isBlank(r.sanctionAuthority()) || StringUtils.isBlank(r.remarks()))) {
throw rule("htm.sale.sanction", "An HTM security may be sold only with the Board's permission: enter the sanction "
+ "authority and the reason for the sale in Remarks");
}
final BigDecimal available = availableUnits(p, ignoreSaleId);
if (r.units().compareTo(available) > 0) {
throw rule("units.exceed", "Only " + available.stripTrailingZeros().toPlainString() + " units of deal " + p.getDealNo()
+ " are available to sell");
}
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
final BigDecimal face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP);
final BigDecimal consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP);
final LocalDate lastCoupon = lastCoupon(p, settlementDate);
final int days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(face, rate, days);
final BigDecimal receivable = receivableShare(p, lastCoupon, settlementDate, r.units()).min(accrued);
final boolean allRemaining = r.units().compareTo(p.getBalanceUnits()) == 0;
// Cost is the units' share of the book value as it stands now (after earlier sales and write-offs).
final BigDecimal book = remainingBook(p);
final BigDecimal bookSold = allRemaining ? book : book.multiply(r.units()).divide(p.getBalanceUnits(), 2, RoundingMode.HALF_UP);
final BigDecimal brokerage = r.brokerage() == null ? BigDecimal.ZERO : r.brokerage();
final InvestmentSale s = new InvestmentSale();
s.setPurchase(p);
s.setSaleType(TYPE_SELL);
s.setParty(r.party());
s.setDealDate(r.dealDate());
s.setDealTime(r.dealTime());
s.setSettlementType(r.settlementType());
s.setSettlementDate(settlementDate);
s.setUnits(r.units());
s.setFaceValue(face);
s.setSaleRate(r.saleRate());
s.setConsideration(consideration);
s.setAccruedDays(days);
s.setAccruedInterest(accrued);
s.setReceivableCleared(receivable);
s.setBrokerage(brokerage);
s.setBookValueSold(bookSold);
final BigDecimal premium = unamortisedPremium(p);
s.setPremiumCleared(allRemaining ? premium : premium.multiply(r.units()).divide(p.getBalanceUnits(), 2, RoundingMode.HALF_UP));
s.setProfitLoss(consideration.subtract(bookSold));
// Jitendra Sir: the sale voucher carries the principal only; the interest is entered by hand (hint above).
s.setNetAmount(consideration.subtract(brokerage));
s.setBankGlAccount(r.bankGlAccountId() == null ? p.getCrGlAccount() : postableAssetGl(r.bankGlAccountId()));
s.setSanctionAuthority(StringUtils.trimToNull(r.sanctionAuthority()));
s.setRemarks(StringUtils.trimToNull(r.remarks()));
s.setStatus(STATUS_PENDING_SETTLEMENT);
return s;
}
/** Repayment at maturity of all units still held, dated the maturity date. */
public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) {
requireActiveDebtHolding(p);
if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE)
+ "; it can be redeemed on or after that date");
}
if (availableUnits(p, null).compareTo(p.getBalanceUnits()) != 0) {
throw rule("redeem.pending.sale", "Deal " + p.getDealNo() + " has a pending sale; settle or drop it before redemption");
}
final BigDecimal face = p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit()).setScale(2, RoundingMode.HALF_UP);
final BigDecimal book = remainingBook(p);
final InvestmentSale s = new InvestmentSale();
s.setPurchase(p);
s.setSaleType(TYPE_REDEEM);
s.setDealDate(p.getMaturityDate());
s.setSettlementDate(p.getMaturityDate());
s.setUnits(p.getBalanceUnits());
s.setFaceValue(face);
s.setSaleRate(HUNDRED);
s.setConsideration(face);
s.setAccruedDays(0);
s.setAccruedInterest(BigDecimal.ZERO);
s.setReceivableCleared(BigDecimal.ZERO);
s.setBrokerage(BigDecimal.ZERO);
s.setBookValueSold(book);
s.setPremiumCleared(unamortisedPremium(p));
s.setProfitLoss(face.subtract(book));
s.setNetAmount(face);
s.setBankGlAccount(bankGlAccountId == null ? p.getCrGlAccount() : postableAssetGl(bankGlAccountId));
s.setRemarks(StringUtils.trimToNull(remarks));
s.setStatus(STATUS_SETTLED);
return s;
}
/**
* Every interest line up to {@code upTo} must be posted first, so the receivable cleared on a sale and the last
* coupon on a redemption are already in the books.
*/
public void requireInterestPostedUpTo(final InvestmentPurchase p, final LocalDate upTo) {
final List<InvestmentInterestPosting> posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(p.getId());
this.purchaseReadPlatformService.scheduleRows(p).stream().filter(r -> !r.date().isAfter(upTo))
.filter(r -> posted.stream().noneMatch(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())))
.findFirst().ifPresent(r -> {
throw rule("interest.pending", "Post the " + r.type().toLowerCase() + " of " + r.date().format(DATE) + " for deal "
+ p.getDealNo() + " first (Interest Entry)");
});
}
/** Units still held less those in other pending sales. */
public BigDecimal availableUnits(final InvestmentPurchase p, final Long ignoreSaleId) {
BigDecimal available = p.getBalanceUnits();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (STATUS_PENDING_SETTLEMENT.equals(s.getStatus()) && !s.getId().equals(ignoreSaleId)) {
available = available.subtract(s.getUnits());
}
}
return available;
}
/** Premium paid over face value not yet taken off by amortisation, sales or the redemption. */
public BigDecimal unamortisedPremium(final InvestmentPurchase p) {
if (p.getPremiumDiscount() == null || p.getPremiumDiscount().signum() <= 0) {
return BigDecimal.ZERO;
}
BigDecimal premium = p.getPremiumDiscount();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (STATUS_SETTLED.equals(s.getStatus()) && s.getPremiumCleared() != null) {
premium = premium.subtract(s.getPremiumCleared());
}
}
return premium.max(BigDecimal.ZERO);
}
/** GL the unamortised premium sits in: 100707 for deals settled since it was introduced, else the investment GL. */
public static Long premiumGlId(final InvestmentPurchase p) {
return p.getPremiumGlAccount() != null ? p.getPremiumGlAccount().getId() : p.getInvestmentGlAccount().getId();
}
/** Book value not yet taken off by settled sales. */
public BigDecimal remainingBook(final InvestmentPurchase p) {
BigDecimal book = p.getBookValue();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (STATUS_SETTLED.equals(s.getStatus())) {
book = book.subtract(s.getBookValueSold());
}
}
return book;
}
/** The coupon date the buyer's interest runs from: the last coupon on or before settlement, never before our purchase. */
private static LocalDate lastCoupon(final InvestmentPurchase p, final LocalDate settlementDate) {
final LocalDate coupon = InvestmentInterestCalculator.lastCouponOnOrBefore(p.getMaturityDate(), settlementDate);
return p.getLastIntDate() != null && p.getLastIntDate().isAfter(coupon) ? p.getLastIntDate() : coupon;
}
/**
* Share of the interest receivable still on the books for this coupon period (provisions posted after the last
* coupon, less what earlier sales cleared) that belongs to the units sold.
*/
private BigDecimal receivableShare(final InvestmentPurchase p, final LocalDate lastCoupon, final LocalDate settlementDate,
final BigDecimal units) {
BigDecimal outstanding = BigDecimal.ZERO;
for (final InvestmentInterestPosting x : this.postingRepository.findByPurchaseIdOrderByIntDateAsc(p.getId())) {
if (InvestmentInterestCalculator.TYPE_PROVISION.equals(x.getLineType()) && x.getIntDate().isAfter(lastCoupon)
&& !x.getIntDate().isAfter(settlementDate)) {
outstanding = outstanding.add(x.getAmount());
}
}
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (STATUS_SETTLED.equals(s.getStatus()) && s.getSettlementDate().isAfter(lastCoupon) && s.getReceivableCleared() != null) {
outstanding = outstanding.subtract(s.getReceivableCleared());
}
}
if (outstanding.signum() <= 0) {
return BigDecimal.ZERO;
}
return outstanding.multiply(units).divide(p.getBalanceUnits(), 2, RoundingMode.HALF_UP);
}
private static void requireActiveDebtHolding(final InvestmentPurchase p) {
if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding");
}
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) {
throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Non-SLR and Bond holdings");
}
}
/** The bank the money comes into must be an enabled ASSET detail GL. */
private GLAccount postableAssetGl(final Long glAccountId) {
final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId);
if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) {
throw rule("bank.glaccount.invalid", "Bank GL " + gl.getGlCode() + " must be an enabled Asset detail account");
}
return gl;
}
static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investmentsale." + code, message);
}
}
@@ -0,0 +1,68 @@
/**
* 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.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentSaleReadPlatformService {
private final InvestmentSaleRepository saleRepository;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleQuoter quoter;
/** Sales and redemptions, newest first — pending ones included. */
public List<InvestmentSaleData> retrieveAll() {
return this.saleRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentSaleData::from).toList();
}
/** What a sale would come to, before it is saved. */
public InvestmentSaleData quoteSale(final Long purchaseId, final LocalDate dealDate, final String settlementType, final BigDecimal units,
final BigDecimal saleRate, final BigDecimal brokerage, final Long bankGlAccountId) {
final InvestmentPurchase p = purchase(purchaseId);
return InvestmentSaleData.from(this.quoter.quoteSale(p, new InvestmentSaleQuoter.SellRequest(null, dealDate, null, settlementType,
units, saleRate, brokerage, bankGlAccountId, null, null), null, true));
}
/** What the redemption at maturity will post, and the units still available to sell. */
public InvestmentSaleData quoteRedemption(final Long purchaseId) {
return InvestmentSaleData.from(this.quoter.quoteRedemption(purchase(purchaseId), null, null));
}
public BigDecimal availableUnits(final Long purchaseId) {
return this.quoter.availableUnits(purchase(purchaseId), null);
}
private InvestmentPurchase purchase(final Long purchaseId) {
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
}
}
@@ -0,0 +1,35 @@
/**
* 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 InvestmentSaleWritePlatformService {
CommandProcessingResult createSale(JsonCommand command);
CommandProcessingResult settleSale(Long saleId, JsonCommand command);
CommandProcessingResult deleteSale(Long saleId);
CommandProcessingResult redeem(Long purchaseId, JsonCommand command);
CommandProcessingResult writeOff(Long purchaseId, JsonCommand command);
}
@@ -0,0 +1,282 @@
/**
* 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.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_REDEEMED;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_SOLD;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.AMOUNT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BANK_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.LOSS_ON_SALE_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PREMIUM_AMORTISATION_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PROFIT_ON_SALE_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PURCHASE_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.REMARKS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SALE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SANCTION_AUTHORITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_PENDING_SETTLEMENT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_SETTLED;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_WRITEOFF;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.UNITS;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
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.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;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentSaleCommandFromApiJsonDeserializer;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Sale and redemption of holdings.
* <ul>
* <li>A sale is saved pending; on or after its settlement date Settle works the figures out again on the books as they
* stand and posts, dated the settlement date: Dr Bank (price + interest − brokerage), Dr Brokerage / Cr Investment
* (cost), Cr Interest Receivable, Cr Interest Income, Cr Profit or Dr Loss.</li>
* <li>A redemption posts at once, dated the maturity date: Dr Bank (face value) / Cr Investment (book value), with a
* discount left going to profit and a premium left written off to the amortisation expense.</li>
* </ul>
*/
@Service
@RequiredArgsConstructor
public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWritePlatformService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final String BROKERAGE_GL_CODE = "500602";
private final InvestmentSaleCommandFromApiJsonDeserializer validator;
private final InvestmentSaleQuoter quoter;
private final InvestmentSaleRepository saleRepository;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentPartyRepository partyRepository;
private final InvestmentJournalPoster journalPoster;
@Override
@Transactional
public CommandProcessingResult createSale(final JsonCommand command) {
this.validator.validateForSell(command.json());
final InvestmentPurchase p = purchase(command.longValueOfParameterNamed(PURCHASE_ID));
final InvestmentSale sale = this.saleRepository.saveAndFlush(this.quoter.quoteSale(p, sellRequest(command), null, false));
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(sale.getId())
.withSubEntityId(p.getId()).withResourceIdAsString(p.getDealNo()).build();
}
@Override
@Transactional
public CommandProcessingResult settleSale(final Long saleId, final JsonCommand command) {
final InvestmentSale pending = sale(saleId);
if (!STATUS_PENDING_SETTLEMENT.equals(pending.getStatus())) {
throw InvestmentSaleQuoter.rule("settle.not.pending", "This sale is not pending settlement");
}
final InvestmentPurchase p = pending.getPurchase();
if (pending.getSettlementDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw InvestmentSaleQuoter.rule("settle.before.date",
"The sale can be settled on or after its settlement date " + pending.getSettlementDate().format(DATE));
}
this.quoter.requireInterestPostedUpTo(p, pending.getSettlementDate());
// The books may have moved since the sale was saved (a provision posted, another sale settled): work it out again.
final InvestmentSale s = this.quoter.quoteSale(p, new InvestmentSaleQuoter.SellRequest(pending.getParty(), pending.getDealDate(),
pending.getDealTime(), pending.getSettlementType(), pending.getUnits(), pending.getSaleRate(), pending.getBrokerage(),
pending.getBankGlAccount().getId(), pending.getSanctionAuthority(), pending.getRemarks()), saleId, false);
// Principal only; the interest from the buyer is a manual entry (shown as a hint on the screen).
final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines().debit(s.getBankGlAccount().getId(), s.getNetAmount())
.debit(this.journalPoster.glId(BROKERAGE_GL_CODE), s.getBrokerage())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), s.getPremiumCleared());
profitOrLoss(lines, s.getProfitLoss(), LOSS_ON_SALE_GL_CODE);
final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment sale settled: deal " + p.getDealNo() + ", " + s.getUnits().stripTrailingZeros().toPlainString() + " units of "
+ p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc() + " to " + s.getParty().getPartyName(),
lines);
copyFigures(s, pending);
pending.setStatus(STATUS_SETTLED);
pending.setGlTransactionId(posted.getTransactionId());
pending.setVoucherNo(voucherNo(posted));
pending.setSettledOnDate(DateUtils.getBusinessLocalDate());
this.saleRepository.saveAndFlush(pending);
p.setBalanceUnits(p.getBalanceUnits().subtract(pending.getUnits()));
if (p.getBalanceUnits().signum() == 0) {
p.setStatus(STATUS_SOLD);
}
this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saleId).withSubEntityId(p.getId())
.withResourceIdAsString(pending.getVoucherNo()).withTransactionId(posted.getTransactionId()).build();
}
@Override
@Transactional
public CommandProcessingResult deleteSale(final Long saleId) {
final InvestmentSale s = sale(saleId);
if (!STATUS_PENDING_SETTLEMENT.equals(s.getStatus())) {
throw InvestmentSaleQuoter.rule("sale.settled", "This sale is already settled and posted to GL, so it cannot be dropped");
}
final Long purchaseId = s.getPurchase().getId();
this.saleRepository.delete(s);
this.saleRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(saleId).withSubEntityId(purchaseId).build();
}
@Override
@Transactional
public CommandProcessingResult redeem(final Long purchaseId, final JsonCommand command) {
this.validator.validateForRedeem(command.json());
final InvestmentPurchase p = purchase(purchaseId);
final Long bankGlId = command.parameterExists(BANK_GL_ACCOUNT_ID) ? command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) : null;
final InvestmentSale s = this.quoter.quoteRedemption(p, bankGlId, command.stringValueOfParameterNamed(REMARKS));
this.quoter.requireInterestPostedUpTo(p, p.getMaturityDate());
final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines().debit(s.getBankGlAccount().getId(), s.getNetAmount())
.credit(p.getInvestmentGlAccount().getId(), s.getBookValueSold().subtract(s.getPremiumCleared()))
.credit(InvestmentSaleQuoter.premiumGlId(p), s.getPremiumCleared());
profitOrLoss(lines, s.getProfitLoss(), PREMIUM_AMORTISATION_GL_CODE);
final CommandProcessingResult posted = this.journalPoster.post(s.getSettlementDate(), p.getDealNo(),
"Investment redeemed at maturity: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " "
+ p.getScript().getScriptDesc(),
lines);
s.setGlTransactionId(posted.getTransactionId());
s.setVoucherNo(voucherNo(posted));
s.setSettledOnDate(DateUtils.getBusinessLocalDate());
final InvestmentSale saved = this.saleRepository.saveAndFlush(s);
p.setBalanceUnits(BigDecimal.ZERO);
p.setStatus(STATUS_REDEEMED);
this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withSubEntityId(purchaseId).withResourceIdAsString(saved.getVoucherNo()).withTransactionId(posted.getTransactionId())
.build();
}
/**
* Writes part of the book value off as a loss (Jitendra Sir: a security bought for 1 lakh now worth 95,000 is
* shown at 95,000): Dr Loss on Investments / Cr Investment, dated today. The units stay held; later sales and the
* redemption work from the reduced book value.
*/
@Override
@Transactional
public CommandProcessingResult writeOff(final Long purchaseId, final JsonCommand command) {
this.validator.validateForWriteOff(command.json());
final InvestmentPurchase p = purchase(purchaseId);
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(AMOUNT).setScale(2, RoundingMode.HALF_UP);
final BigDecimal book = this.quoter.remainingBook(p);
if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw InvestmentSaleQuoter.rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding");
}
if (amount.compareTo(book) >= 0) {
throw InvestmentSaleQuoter.rule("writeoff.exceeds", "The write-off must be less than the book value of "
+ book.stripTrailingZeros().toPlainString() + "; sell or redeem the security to close it");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final CommandProcessingResult posted = this.journalPoster.post(today, p.getDealNo(),
"Investment written off: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(),
new InvestmentJournalPoster.Lines().debit(this.journalPoster.glId(LOSS_ON_SALE_GL_CODE), amount)
.credit(p.getInvestmentGlAccount().getId(), amount));
final InvestmentSale s = new InvestmentSale();
s.setPurchase(p);
s.setSaleType(TYPE_WRITEOFF);
s.setDealDate(today);
s.setSettlementDate(today);
s.setUnits(BigDecimal.ZERO);
s.setFaceValue(BigDecimal.ZERO);
s.setConsideration(BigDecimal.ZERO);
s.setAccruedDays(0);
s.setAccruedInterest(BigDecimal.ZERO);
s.setReceivableCleared(BigDecimal.ZERO);
s.setBrokerage(BigDecimal.ZERO);
s.setBookValueSold(amount);
s.setPremiumCleared(BigDecimal.ZERO);
s.setProfitLoss(amount.negate());
s.setNetAmount(BigDecimal.ZERO);
s.setBankGlAccount(p.getCrGlAccount());
s.setRemarks(command.stringValueOfParameterNamed(REMARKS).trim());
s.setStatus(STATUS_SETTLED);
s.setGlTransactionId(posted.getTransactionId());
s.setVoucherNo(voucherNo(posted));
s.setSettledOnDate(today);
final InvestmentSale saved = this.saleRepository.saveAndFlush(s);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
.withSubEntityId(purchaseId).withResourceIdAsString(saved.getVoucherNo()).withTransactionId(posted.getTransactionId())
.build();
}
/** Profit is credited to income; a shortfall is debited to {@code lossGlCode}. */
private void profitOrLoss(final InvestmentJournalPoster.Lines lines, final BigDecimal profitLoss, final String lossGlCode) {
if (profitLoss.signum() > 0) {
lines.credit(this.journalPoster.glId(PROFIT_ON_SALE_GL_CODE), profitLoss);
} else if (profitLoss.signum() < 0) {
lines.debit(this.journalPoster.glId(lossGlCode), profitLoss.negate());
}
}
private InvestmentSaleQuoter.SellRequest sellRequest(final JsonCommand command) {
final Long partyId = command.longValueOfParameterNamed(PARTY_ID);
final InvestmentParty party = this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE);
return new InvestmentSaleQuoter.SellRequest(party, dealDate, command.stringValueOfParameterNamed(DEAL_TIME),
command.stringValueOfParameterNamed(SETTLEMENT_TYPE), command.bigDecimalValueOfParameterNamed(UNITS),
command.bigDecimalValueOfParameterNamed(SALE_RATE), command.bigDecimalValueOfParameterNamed(BROKERAGE),
command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID), command.stringValueOfParameterNamed(SANCTION_AUTHORITY),
command.stringValueOfParameterNamed(REMARKS));
}
private static void copyFigures(final InvestmentSale from, final InvestmentSale to) {
to.setAccruedDays(from.getAccruedDays());
to.setAccruedInterest(from.getAccruedInterest());
to.setReceivableCleared(from.getReceivableCleared());
to.setBookValueSold(from.getBookValueSold());
to.setPremiumCleared(from.getPremiumCleared());
to.setProfitLoss(from.getProfitLoss());
to.setNetAmount(from.getNetAmount());
}
private static String voucherNo(final CommandProcessingResult posted) {
final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo");
return voucherNo == null ? null : voucherNo.toString();
}
private InvestmentPurchase purchase(final Long purchaseId) {
return this.purchaseRepository.findById(purchaseId).orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
}
private InvestmentSale sale(final Long saleId) {
return this.saleRepository.findById(saleId)
.orElseThrow(() -> InvestmentSaleQuoter.rule("sale.not.found", "Investment sale " + saleId + " does not exist"));
}
}
@@ -0,0 +1,171 @@
/**
* 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.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import com.google.gson.JsonElement;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.ApiParameterError;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentSecurityTransferData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer;
import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransferRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* GOI Category Transfer as Jitendra Sir describes it: a holding is replaced by another government security of the same
* terms (same coupon rate, maturity and unit value, e.g. one state loan for another). Units, book value and the interest
* schedule carry over unchanged; when the new script maps to a different investment GL the book value is moved across,
* Dr new GL / Cr old GL, dated the transfer date.
*/
@Service
@RequiredArgsConstructor
public class InvestmentSecurityTransferService {
public static final String SCRIPT_ID = "scriptId";
public static final String REMARKS = "remarks";
private static final Set<String> PARAMS = Set.of(SCRIPT_ID, REMARKS, "locale", "dateFormat");
private final FromJsonHelper fromApiJsonHelper;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentScriptRepository scriptRepository;
private final InvestmentSecurityTransferRepository transferRepository;
private final InvestmentSaleQuoter quoter;
private final InvestmentJournalPoster journalPoster;
@Transactional(readOnly = true)
public List<InvestmentSecurityTransferData> retrieveAll() {
return this.transferRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentSecurityTransferData::from)
.toList();
}
@Transactional
public CommandProcessingResult transfer(final Long purchaseId, final JsonCommand command) {
validate(command.json());
final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
final Long scriptId = command.longValueOfParameterNamed(SCRIPT_ID);
final InvestmentScript to = this.scriptRepository.findById(scriptId).orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final InvestmentScript from = p.getScript();
if (!STATUS_ACTIVE.equals(p.getStatus()) || !InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active Govt Security / Bond holding");
}
if (this.quoter.availableUnits(p, null).compareTo(p.getBalanceUnits()) != 0) {
throw rule("pending.sale", "Deal " + p.getDealNo() + " has a pending sale; settle or drop it first");
}
if (to.getId().equals(from.getId())) {
throw rule("same.script", "Choose a different security to transfer to");
}
if (!to.isActive()) {
throw rule("script.inactive", "Script " + to.getScriptCode() + " is inactive");
}
if (!to.getBondType().equals(p.getBondType()) || !sameTerms(from, to)) {
throw rule("terms.differ", "Script " + to.getScriptCode() + " must have the same bond type, coupon rate, maturity date and "
+ "unit value as " + from.getScriptCode() + "; only the security changes, everything else stays the same");
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final BigDecimal book = this.quoter.remainingBook(p);
final InvestmentSecurityTransfer t = new InvestmentSecurityTransfer();
t.setPurchase(p);
t.setFromScript(from);
t.setToScript(to);
t.setFromGlAccount(p.getInvestmentGlAccount());
t.setToGlAccount(to.getGlAccount());
t.setTransferDate(today);
t.setUnits(p.getBalanceUnits());
t.setBookValue(book);
t.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS)));
String transactionId = null;
if (!to.getGlAccount().getId().equals(p.getInvestmentGlAccount().getId())) {
// Unamortised premium kept in its own GL (100707) stays there; only the investment GL's part moves.
final BigDecimal moved = p.getPremiumGlAccount() != null ? book.subtract(this.quoter.unamortisedPremium(p)) : book;
final CommandProcessingResult posted = this.journalPoster.post(today, p.getDealNo(),
"GOI category transfer: deal " + p.getDealNo() + " from " + from.getScriptCode() + " " + from.getScriptDesc() + " to "
+ to.getScriptCode() + " " + to.getScriptDesc(),
new InvestmentJournalPoster.Lines().debit(to.getGlAccount().getId(), moved).credit(p.getInvestmentGlAccount().getId(),
moved));
transactionId = posted.getTransactionId();
final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo");
t.setGlTransactionId(transactionId);
t.setVoucherNo(voucherNo == null ? null : voucherNo.toString());
}
final InvestmentSecurityTransfer saved = this.transferRepository.saveAndFlush(t);
p.setScript(to);
p.setInvestmentGlAccount(to.getGlAccount());
this.purchaseRepository.saveAndFlush(p);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).withSubEntityId(purchaseId)
.withResourceIdAsString(saved.getVoucherNo() == null ? p.getDealNo() : saved.getVoucherNo()).withTransactionId(transactionId)
.build();
}
private static boolean sameTerms(final InvestmentScript a, final InvestmentScript b) {
return a.getIntRate() != null && b.getIntRate() != null && a.getIntRate().compareTo(b.getIntRate()) == 0
&& a.getMaturityDate() != null && a.getMaturityDate().equals(b.getMaturityDate())
&& a.getFaceValuePerUnit().compareTo(b.getFaceValuePerUnit()) == 0;
}
private void validate(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("investmenttransfer");
v.reset().parameter(SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(SCRIPT_ID, element)).notNull().longGreaterThanZero();
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).ignoreIfNull()
.notExceedingLengthOf(500);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investmenttransfer." + code, message);
}
}
@@ -180,4 +180,7 @@
<include file="parts/0596_investment_daily_rate.xml" relativeToChangelogFile="true"/>
<include file="parts/0597_investment_purchase_settlement.xml" relativeToChangelogFile="true"/>
<include file="parts/0600_investment_interest_posting.xml" relativeToChangelogFile="true"/>
<include file="parts/0601_investment_sale_redemption.xml" relativeToChangelogFile="true"/>
<include file="parts/0602_investment_security_transfer.xml" relativeToChangelogFile="true"/>
<include file="parts/0603_investment_premium_amortisation.xml" relativeToChangelogFile="true"/>
</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">
<!--
Sale (before maturity, all or part of the units) and redemption (at maturity) of investment holdings.
A sale is saved pending and posted to GL on its settlement date (settlement-date accounting, like the purchase);
a redemption is posted at once, dated the maturity date.
-->
<changeSet author="cbs-anshul" id="0601-1-create-investment-sale">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_sale"/></not>
</preConditions>
<createTable tableName="m_investment_sale">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="purchase_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="sale_type" type="VARCHAR(10)"><constraints nullable="false"/></column>
<column name="party_id" type="BIGINT"/>
<column name="deal_date" type="DATE"><constraints nullable="false"/></column>
<column name="deal_time" type="VARCHAR(8)"/>
<column name="settlement_type" type="VARCHAR(5)"/>
<column name="settlement_date" type="DATE"><constraints nullable="false"/></column>
<column name="units" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="face_value" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="sale_rate" type="DECIMAL(19,6)"/>
<column name="consideration" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="accrued_days" type="INT"/>
<column name="accrued_interest" type="DECIMAL(19,6)"/>
<column name="receivable_cleared" type="DECIMAL(19,6)"/>
<column name="brokerage" type="DECIMAL(19,6)"/>
<column name="book_value_sold" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="profit_loss" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="net_amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="bank_gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="sanction_authority" type="VARCHAR(100)"/>
<column name="remarks" type="VARCHAR(500)"/>
<column name="status" type="VARCHAR(20)"><constraints nullable="false"/></column>
<column name="gl_transaction_id" type="VARCHAR(50)"/>
<column name="voucher_no" type="VARCHAR(30)"/>
<column name="settled_on_date" type="DATE"/>
<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>
<addForeignKeyConstraint baseTableName="m_investment_sale" baseColumnNames="purchase_id" constraintName="fk_investment_sale_purchase"
referencedTableName="m_investment_purchase" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_sale" baseColumnNames="party_id" constraintName="fk_investment_sale_party"
referencedTableName="m_investment_party" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_sale" baseColumnNames="bank_gl_account_id" constraintName="fk_investment_sale_bank_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</changeSet>
<!-- Sell follows the purchase: saving and settling go through maker-checker; a redemption posts at once like an interest voucher. -->
<changeSet author="cbs-anshul" id="0601-2-investment-sale-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE entity_name = 'INVESTMENTSALE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES
('portfolio', 'READ_INVESTMENTSALE', 'INVESTMENTSALE', 'READ', FALSE),
('portfolio', 'CREATE_INVESTMENTSALE', 'INVESTMENTSALE', 'CREATE', TRUE),
('portfolio', 'CREATE_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'CREATE_CHECKER', FALSE),
('portfolio', 'SETTLE_INVESTMENTSALE', 'INVESTMENTSALE', 'SETTLE', TRUE),
('portfolio', 'SETTLE_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'SETTLE_CHECKER', FALSE),
('portfolio', 'DELETE_INVESTMENTSALE', 'INVESTMENTSALE', 'DELETE', FALSE),
('portfolio', 'DELETE_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'DELETE_CHECKER', FALSE),
('portfolio', 'REDEEM_INVESTMENTSALE', 'INVESTMENTSALE', 'REDEEM', FALSE),
('portfolio', 'REDEEM_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'REDEEM_CHECKER', FALSE)
</sql>
</changeSet>
<!-- Same roles as the other investment screens. -->
<changeSet author="cbs-anshul" id="0601-3-grant-investment-sale-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_INVESTMENTSALE', 'CREATE_INVESTMENTSALE', 'SETTLE_INVESTMENTSALE', 'DELETE_INVESTMENTSALE',
'REDEEM_INVESTMENTSALE')
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>
@@ -0,0 +1,113 @@
<?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">
<!--
GOI Category Transfer (Jitendra Sir, 15-10-2026): a holding moves from one government security script to another
of the same terms (e.g. one state's loan to another's) — "replace to new". Units, book value and the interest
schedule stay; the investment GL moves only if the new script maps to a different GL.
-->
<changeSet author="cbs-anshul" id="0602-1-create-investment-security-transfer">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_security_transfer"/></not>
</preConditions>
<createTable tableName="m_investment_security_transfer">
<column name="id" type="BIGINT" autoIncrement="true"><constraints nullable="false" primaryKey="true"/></column>
<column name="purchase_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_script_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_script_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="from_gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="to_gl_account_id" type="BIGINT"><constraints nullable="false"/></column>
<column name="transfer_date" type="DATE"><constraints nullable="false"/></column>
<column name="units" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="book_value" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="remarks" type="VARCHAR(500)"/>
<column name="gl_transaction_id" type="VARCHAR(50)"/>
<column name="voucher_no" type="VARCHAR(30)"/>
<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>
<addForeignKeyConstraint baseTableName="m_investment_security_transfer" baseColumnNames="purchase_id" constraintName="fk_inv_sec_transfer_purchase"
referencedTableName="m_investment_purchase" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_security_transfer" baseColumnNames="from_script_id" constraintName="fk_inv_sec_transfer_from_script"
referencedTableName="m_investment_script" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_security_transfer" baseColumnNames="to_script_id" constraintName="fk_inv_sec_transfer_to_script"
referencedTableName="m_investment_script" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_security_transfer" baseColumnNames="from_gl_account_id" constraintName="fk_inv_sec_transfer_from_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_security_transfer" baseColumnNames="to_gl_account_id" constraintName="fk_inv_sec_transfer_to_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</changeSet>
<changeSet author="cbs-anshul" id="0602-2-investment-security-transfer-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE entity_name = 'INVESTMENTTRANSFER'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES
('portfolio', 'READ_INVESTMENTTRANSFER', 'INVESTMENTTRANSFER', 'READ', FALSE),
('portfolio', 'CREATE_INVESTMENTTRANSFER', 'INVESTMENTTRANSFER', 'CREATE', FALSE),
('portfolio', 'CREATE_INVESTMENTTRANSFER_CHECKER', 'INVESTMENTTRANSFER', 'CREATE_CHECKER', FALSE)
</sql>
</changeSet>
<!-- Same roles as the other investment screens. -->
<changeSet author="cbs-anshul" id="0602-3-grant-investment-security-transfer-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_INVESTMENTTRANSFER', 'CREATE_INVESTMENTTRANSFER')
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>
<!-- Write-off of part of a holding's book value (Jitendra Sir, 15-10-2026): posts at once, like a redemption. -->
<changeSet author="cbs-anshul" id="0602-4-investment-writeoff-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'WRITEOFF_INVESTMENTSALE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES
('portfolio', 'WRITEOFF_INVESTMENTSALE', 'INVESTMENTSALE', 'WRITEOFF', FALSE),
('portfolio', 'WRITEOFF_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'WRITEOFF_CHECKER', FALSE)
</sql>
</changeSet>
<changeSet author="cbs-anshul" id="0602-5-grant-investment-writeoff-permission">
<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 = 'WRITEOFF_INVESTMENTSALE'
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>
@@ -0,0 +1,83 @@
<?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">
<!--
Premium amortisation of HTM holdings (RBI: premium over face value is amortised over the period remaining to
maturity; Jitendra Sir: kept in its own GL 100707). New settlements book the premium to that GL; each sale,
redemption and amortisation records how much premium it took off.
-->
<changeSet author="cbs-anshul" id="0603-1-investment-premium-columns">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_purchase" columnName="premium_gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_investment_purchase">
<column name="premium_gl_account_id" type="BIGINT"/>
</addColumn>
<addForeignKeyConstraint baseTableName="m_investment_purchase" baseColumnNames="premium_gl_account_id"
constraintName="fk_investment_purchase_premium_gl" referencedTableName="acc_gl_account" referencedColumnNames="id"/>
<addColumn tableName="m_investment_sale">
<column name="premium_cleared" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
<!--
Sales and redemptions settled before premium tracking took premium off in proportion to units (nothing had been
amortised yet), so their share is premium x units sold / units bought; write-offs took none.
-->
<changeSet author="cbs-anshul" id="0603-4-backfill-investment-premium-cleared">
<sql>
UPDATE m_investment_sale SET premium_cleared = COALESCE((
SELECT ROUND(p.premium_discount * m_investment_sale.units / p.units, 2)
FROM m_investment_purchase p
WHERE p.id = m_investment_sale.purchase_id AND p.premium_discount &gt; 0 AND p.units &gt; 0), 0)
WHERE premium_cleared IS NULL AND sale_type IN ('SELL', 'REDEEM');
UPDATE m_investment_sale SET premium_cleared = 0 WHERE premium_cleared IS NULL;
</sql>
</changeSet>
<changeSet author="cbs-anshul" id="0603-2-investment-amortise-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'AMORTISE_INVESTMENTSALE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES
('portfolio', 'AMORTISE_INVESTMENTSALE', 'INVESTMENTSALE', 'AMORTISE', FALSE),
('portfolio', 'AMORTISE_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'AMORTISE_CHECKER', FALSE)
</sql>
</changeSet>
<changeSet author="cbs-anshul" id="0603-3-grant-investment-amortise-permission">
<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 = 'AMORTISE_INVESTMENTSALE'
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>