feat: add investment portfolio module with support for fixed deposits, shares, valuation, and purchase management
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Waiting to run
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Waiting to run
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Waiting to run
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Waiting to run
Fineract Documentation build / build (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Waiting to run
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Waiting to run
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (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-3) (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-28 17:18:48 +05:30
parent ff772d03b1
commit d5f188258f
31 changed files with 998 additions and 24 deletions
@@ -263,6 +263,14 @@ public class CommandWrapperBuilder {
return this; return this;
} }
public CommandWrapperBuilder dividendInvestmentPurchase(final Long purchaseId) {
this.actionName = "DIVIDEND";
this.entityName = "INVESTMENTPURCHASE";
this.entityId = purchaseId;
this.href = "/investment/purchases/" + purchaseId + "/dividends";
return this;
}
public CommandWrapperBuilder createInvestmentValuation() { public CommandWrapperBuilder createInvestmentValuation() {
this.actionName = "CREATE"; this.actionName = "CREATE";
this.entityName = "INVESTMENTVALUATION"; this.entityName = "INVESTMENTVALUATION";
@@ -71,6 +71,19 @@ public final class InvestmentApiConstants {
/** RBI: only debt and money-market mutual funds; their sub types start with this. */ /** RBI: only debt and money-market mutual funds; their sub types start with this. */
public static final String DEBT_MF_SUB_TYPE_PREFIX = "DEBT"; public static final String DEBT_MF_SUB_TYPE_PREFIX = "DEBT";
/** Share investments: only these institutions (no shares of companies outside the co-operative sector). */
public static final String SHARE = "SHARE";
public static final String INSTITUTION_STATE_COOP_BANK = "STATE_COOP_BANK";
public static final String INSTITUTION_DCCB = "DCCB";
public static final String INSTITUTION_OTHER_COOP_SOCIETY = "OTHER_COOP_SOCIETY";
/** Membership shares of NPCI, CCIL, SWIFT, the Umbrella Organisation of UCBs and IDPIC. */
public static final String INSTITUTION_MEMBERSHIP_MIC_UO = "MEMBERSHIP_MIC_UO";
public static final Set<String> INSTITUTION_TYPES = Set.of(INSTITUTION_STATE_COOP_BANK, INSTITUTION_DCCB, INSTITUTION_OTHER_COOP_SOCIETY,
INSTITUTION_MEMBERSHIP_MIC_UO);
/** Co-operative institutions: their shares are valued on the dividend / balance sheet rules. */
public static final Set<String> COOP_INSTITUTION_TYPES = Set.of(INSTITUTION_STATE_COOP_BANK, INSTITUTION_DCCB,
INSTITUTION_OTHER_COOP_SOCIETY);
public static final String PARTY_CODE_PREFIX = "PTY-"; public static final String PARTY_CODE_PREFIX = "PTY-";
// Common parameters // Common parameters
@@ -100,6 +113,10 @@ public final class InvestmentApiConstants {
public static final String CP_IFSC = "cpIfsc"; public static final String CP_IFSC = "cpIfsc";
public static final String TDS_APPLICABLE = "tdsApplicable"; public static final String TDS_APPLICABLE = "tdsApplicable";
public static final String TDS_RATE = "tdsRate"; public static final String TDS_RATE = "tdsRate";
public static final String INSTITUTION_TYPE = "institutionType";
public static final String SUBSCRIBED_CAPITAL = "subscribedCapital";
public static final String LAST_BALANCE_SHEET_DATE = "lastBalanceSheetDate";
public static final String IN_LIQUIDATION = "inLiquidation";
// Script parameters // Script parameters
public static final String SLR_TYPE = "slrType"; public static final String SLR_TYPE = "slrType";
@@ -117,7 +134,7 @@ public final class InvestmentApiConstants {
public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, public static final Set<String> PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX,
BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE, BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE,
TDS_APPLICABLE, TDS_RATE, LOCALE); TDS_APPLICABLE, TDS_RATE, INSTITUTION_TYPE, SUBSCRIBED_CAPITAL, LAST_BALANCE_SHEET_DATE, IN_LIQUIDATION, LOCALE, DATE_FORMAT);
public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT, public static final Set<String> SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT,
INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE, INT_RATE, ISSUE_DATE, MATURITY_DATE, COMPANY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, CORP_CODE,
@@ -70,6 +70,7 @@ public final class InvestmentFdApiConstants {
public static final String REMARKS = "remarks"; public static final String REMARKS = "remarks";
public static final String AS_ON_DATE = "asOnDate"; public static final String AS_ON_DATE = "asOnDate";
public static final String TOTAL_DEPOSITS = "totalDeposits"; public static final String TOTAL_DEPOSITS = "totalDeposits";
public static final String OWNED_FUNDS = "ownedFunds";
public static final String LOCALE = "locale"; public static final String LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat"; public static final String DATE_FORMAT = "dateFormat";
@@ -115,5 +116,5 @@ public final class InvestmentFdApiConstants {
public static final Set<String> FD_PARAMS = Set.of(PARTY_ID, CERT_NO, CERT_DATE, DEAL_DATE, AMOUNT, INT_RATE, MATURITY_DATE, PAYOUT, public static final Set<String> FD_PARAMS = Set.of(PARTY_ID, CERT_NO, CERT_DATE, DEAL_DATE, AMOUNT, INT_RATE, MATURITY_DATE, PAYOUT,
CALC_TYPE, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); CALC_TYPE, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
public static final Set<String> BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, REMARKS, LOCALE, DATE_FORMAT); public static final Set<String> BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, OWNED_FUNDS, REMARKS, LOCALE, DATE_FORMAT);
} }
@@ -73,4 +73,12 @@ public final class InvestmentPurchaseApiConstants {
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE, public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE, PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
OTHER_DETAILS, LOCALE, DATE_FORMAT); OTHER_DETAILS, LOCALE, DATE_FORMAT);
/** Dividend received on a share holding: Dr Bank / Cr 400404 Dividend on Investments, dated the day it is received. */
public static final String DIVIDEND_GL_CODE = "400404";
public static final String DIVIDEND_DATE = "dividendDate";
public static final String AMOUNT = "amount";
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String REMARKS = "remarks";
public static final Set<String> DIVIDEND_PARAMS = Set.of(DIVIDEND_DATE, AMOUNT, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT);
} }
@@ -41,13 +41,16 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentDividendData;
import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData; import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData; import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.data.InvestmentShareLimitData;
import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService; import org.apache.fineract.portfolio.investment.service.InvestmentNonSlrService;
import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService;
import org.apache.fineract.portfolio.investment.service.InvestmentShareService;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
@Path("/v1/investment/purchases") @Path("/v1/investment/purchases")
@@ -61,6 +64,7 @@ public class InvestmentPurchaseApiResource {
private final InvestmentSaleReadPlatformService saleReadPlatformService; private final InvestmentSaleReadPlatformService saleReadPlatformService;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final InvestmentNonSlrService nonSlrService; private final InvestmentNonSlrService nonSlrService;
private final InvestmentShareService shareService;
@GET @GET
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@@ -83,6 +87,29 @@ public class InvestmentPurchaseApiResource {
return this.nonSlrService.limits(date, scriptId, amount); return this.nonSlrService.limits(date, scriptId, amount);
} }
@GET
@Path("share-limit")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "RBI limits for a share purchase", description = "Where a purchase of amount (book value) of shares of partyId "
+ "on dealDate (dd-MM-yyyy, default today) stands against the 2% of owned funds and 5% of the society's capital limits; "
+ "membership shares are exempt.")
public InvestmentShareLimitData shareLimit(@QueryParam("partyId") @Parameter(description = "partyId") final Long partyId,
@QueryParam("dealDate") @Parameter(description = "dd-MM-yyyy") final String dealDate,
@QueryParam("amount") @Parameter(description = "amount") final BigDecimal amount) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
final LocalDate date = InvestmentApiDates.parse(dealDate, DateUtils.getBusinessLocalDate(), "deal date");
return this.shareService.limits(date, partyId, amount);
}
@GET
@Path("dividends")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Dividends received on share holdings", description = "Newest first, with their vouchers.")
public List<InvestmentDividendData> retrieveDividends() {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
return this.shareService.retrieveDividends();
}
@GET @GET
@Path("{purchaseId}") @Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@@ -176,6 +203,19 @@ public class InvestmentPurchaseApiResource {
return this.commandsSourceWritePlatformService.logCommandSource(request); return this.commandsSourceWritePlatformService.logCommandSource(request);
} }
@POST
@Path("{purchaseId}/dividends")
@Consumes({ MediaType.APPLICATION_JSON })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Record a dividend received on a share holding", description = "dividendDate, amount, bankGlAccountId, "
+ "remarks: Dr the bank GL / Cr 400404 Dividend on Investments, dated the day it was received.")
public CommandProcessingResult postDividend(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
final CommandWrapper request = new CommandWrapperBuilder().dividendInvestmentPurchase(purchaseId).withJson(apiRequestBodyAsJson)
.build();
return this.commandsSourceWritePlatformService.logCommandSource(request);
}
@POST @POST
@Path("{purchaseId}/transfer") @Path("{purchaseId}/transfer")
@Consumes({ MediaType.APPLICATION_JSON }) @Consumes({ MediaType.APPLICATION_JSON })
@@ -35,6 +35,8 @@ public final class InvestmentValuationApiConstants {
public static final String CLASS_GOVT = "GOVT_SECURITY"; public static final String CLASS_GOVT = "GOVT_SECURITY";
public static final String CLASS_BOND = "CORPORATE_BOND"; public static final String CLASS_BOND = "CORPORATE_BOND";
public static final String CLASS_FUND = "MUTUAL_FUND"; public static final String CLASS_FUND = "MUTUAL_FUND";
/** Shares are valued by rule, not at a market rate: face value, nil, or ₹1 per institution (see the valuation service). */
public static final String CLASS_SHARE = "SHARE";
/** Depreciation charged to P&L (Provisions &amp; Contingencies) and the reserve it is held in. */ /** Depreciation charged to P&L (Provisions &amp; Contingencies) and the reserve it is held in. */
public static final String DEPRECIATION_PROVISION_GL_CODE = "500504"; public static final String DEPRECIATION_PROVISION_GL_CODE = "500504";
@@ -0,0 +1,57 @@
/**
* 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.InvestmentDividend;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
/** A dividend received on a share holding, with its voucher. */
@Getter
@Builder
public class InvestmentDividendData {
private final Long id;
private final Long purchaseId;
private final String dealNo;
private final String certNo;
private final String scriptCode;
private final String scriptDesc;
private final Long partyId;
private final String partyName;
private final String dividendDate;
private final BigDecimal amount;
private final String bankGlCode;
private final String bankGlName;
private final String remarks;
private final String glTransactionId;
private final String voucherNo;
public static InvestmentDividendData from(final InvestmentDividend d) {
final InvestmentPurchase p = d.getPurchase();
return InvestmentDividendData.builder().id(d.getId()).purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).partyId(p.getParty().getId())
.partyName(p.getParty().getPartyName()).dividendDate(d.getDividendDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")))
.amount(d.getAmount()).bankGlCode(d.getBankGlAccount().getGlCode()).bankGlName(d.getBankGlAccount().getName())
.remarks(d.getRemarks()).glTransactionId(d.getGlTransactionId()).voucherNo(d.getVoucherNo()).build();
}
}
@@ -31,6 +31,8 @@ public class InvestmentFdLimitData {
private final String asOnDate; private final String asOnDate;
/** Total deposits as on that date, as entered; empty until someone enters it. */ /** Total deposits as on that date, as entered; empty until someone enters it. */
private final BigDecimal totalDeposits; private final BigDecimal totalDeposits;
/** Owned funds (paid-up share capital + reserves) as on that date, the base of the 2% limit on co-operative shares. */
private final BigDecimal ownedFunds;
/** The same figure worked out from the deposit GLs, as a suggestion. */ /** The same figure worked out from the deposit GLs, as a suggestion. */
private final BigDecimal glTotalDeposits; private final BigDecimal glTotalDeposits;
private final BigDecimal grossLimit; private final BigDecimal grossLimit;
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.investment.data; package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.format.DateTimeFormatter;
import lombok.Builder; import lombok.Builder;
import lombok.Getter; import lombok.Getter;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty; import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
@@ -54,6 +55,10 @@ public class InvestmentPartyData {
private final String cpBranchName; private final String cpBranchName;
private final String cpAccountNo; private final String cpAccountNo;
private final String cpIfsc; private final String cpIfsc;
private final String institutionType;
private final BigDecimal subscribedCapital;
private final String lastBalanceSheetDate;
private final boolean inLiquidation;
private final boolean active; private final boolean active;
public static InvestmentPartyData from(final InvestmentParty p) { public static InvestmentPartyData from(final InvestmentParty p) {
@@ -66,6 +71,10 @@ public class InvestmentPartyData {
.interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName()) .interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName())
.tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).rbiCode(p.getRbiCode()).limit(p.getExposureLimit()) .tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).rbiCode(p.getRbiCode()).limit(p.getExposureLimit())
.leiNo(p.getLeiNo()).cpBankName(p.getCpBankName()) .leiNo(p.getLeiNo()).cpBankName(p.getCpBankName())
.cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc()).active(p.isActive()).build(); .cpBranchName(p.getCpBranchName()).cpAccountNo(p.getCpAccountNo()).cpIfsc(p.getCpIfsc())
.institutionType(p.getInstitutionType()).subscribedCapital(p.getSubscribedCapital())
.lastBalanceSheetDate(p.getLastBalanceSheetDate() == null ? null
: p.getLastBalanceSheetDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy")))
.inLiquidation(p.isInLiquidation()).active(p.isActive()).build();
} }
} }
@@ -0,0 +1,51 @@
/**
* 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;
/**
* Where a share purchase stands against the RBI limits: shares of co-operative societies within 2% of owned funds, and
* within 5% of one society's subscribed capital. Membership shares (DCCB, State Co-op Bank, NPCI / CCIL / SWIFT, Umbrella
* Organisation, IDPIC) may exceed them.
*/
@Getter
@Builder
public class InvestmentShareLimitData {
/** The 31 March the owned funds are taken from. */
private final String asOnDate;
private final String institutionType;
/** True for membership shares, which the limits do not bind. */
private final boolean exempt;
private final BigDecimal ownedFunds;
private final BigDecimal coopLimit;
private final BigDecimal coopOutstanding;
private final BigDecimal coopAfter;
private final String partyName;
private final BigDecimal subscribedCapital;
private final BigDecimal institutionLimit;
private final BigDecimal institutionOutstanding;
private final BigDecimal institutionAfter;
private final BigDecimal newAmount;
private final boolean withinLimits;
private final String message;
}
@@ -81,5 +81,8 @@ public class InvestmentValuationData {
private final String rateDate; private final String rateDate;
private final BigDecimal marketValue; private final BigDecimal marketValue;
private final BigDecimal difference; private final BigDecimal difference;
/** How a share was valued; empty for a holding valued at its market rate. */
private final String valuationBasis;
private final boolean npi;
} }
} }
@@ -42,6 +42,10 @@ public class InvestmentDepositBase extends AbstractAuditableWithUTCDateTimeCusto
@Column(name = "total_deposits", scale = 6, precision = 19, nullable = false) @Column(name = "total_deposits", scale = 6, precision = 19, nullable = false)
private BigDecimal totalDeposits; private BigDecimal totalDeposits;
/** Owned funds (paid-up share capital + reserves) on the same date: shares of co-operative societies are limited to 2% of it. */
@Column(name = "owned_funds", scale = 6, precision = 19)
private BigDecimal ownedFunds;
@Column(name = "remarks", length = 500) @Column(name = "remarks", length = 500)
private String remarks; private String remarks;
} }
@@ -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.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 dividend received on a share holding: booked as income when it is received (Dr Bank / Cr Dividend on Investments). */
@Entity
@Table(name = "m_investment_dividend")
@Getter
@Setter
@NoArgsConstructor
public class InvestmentDividend extends AbstractAuditableWithUTCDateTimeCustom<Long> {
@ManyToOne
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
@Column(name = "dividend_date", nullable = false)
private LocalDate dividendDate;
@Column(name = "amount", scale = 6, precision = 19, nullable = false)
private BigDecimal amount;
@ManyToOne
@JoinColumn(name = "bank_gl_account_id", nullable = false)
private GLAccount bankGlAccount;
@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,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.domain;
import java.time.LocalDate;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface InvestmentDividendRepository extends JpaRepository<InvestmentDividend, Long> {
List<InvestmentDividend> findAllByOrderByDividendDateDescIdDesc();
/** Whether the institution has paid us any dividend on or before a date (on any of our holdings of its shares). */
boolean existsByPurchasePartyIdAndDividendDateLessThanEqual(Long partyId, LocalDate date);
}
@@ -24,6 +24,7 @@ import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter; import lombok.Getter;
import lombok.NoArgsConstructor; import lombok.NoArgsConstructor;
import lombok.Setter; import lombok.Setter;
@@ -112,6 +113,22 @@ public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom<Long
@Column(name = "cp_ifsc", length = 11) @Column(name = "cp_ifsc", length = 11)
private String cpIfsc; private String cpIfsc;
/** Share institutions only: DCCB, State Co-op Bank, other co-operative society, or MIC / Umbrella Organisation. */
@Column(name = "institution_type", length = 30)
private String institutionType;
/** The institution's subscribed share capital: our holding in a co-operative society is limited to 5% of it. */
@Column(name = "subscribed_capital", scale = 6, precision = 19)
private BigDecimal subscribedCapital;
/** Date of the institution's latest balance sheet we have; one older than 18 months values its shares at ₹1. */
@Column(name = "last_balance_sheet_date")
private LocalDate lastBalanceSheetDate;
/** An institution in liquidation: its shares are fully provided for. */
@Column(name = "is_in_liquidation", nullable = false)
private boolean inLiquidation;
/** An inactive party is kept for history but not offered for new deals. */ /** An inactive party is kept for history but not offered for new deals. */
@Column(name = "is_active", nullable = false) @Column(name = "is_active", nullable = false)
private boolean active = true; private boolean active = true;
@@ -55,6 +55,9 @@ public class InvestmentValuation extends AbstractAuditableWithUTCDateTimeCustom<
@Column(name = "fund_required", scale = 6, precision = 19, nullable = false) @Column(name = "fund_required", scale = 6, precision = 19, nullable = false)
private BigDecimal fundRequired; private BigDecimal fundRequired;
@Column(name = "share_required", scale = 6, precision = 19, nullable = false)
private BigDecimal shareRequired;
@Column(name = "required_total", scale = 6, precision = 19, nullable = false) @Column(name = "required_total", scale = 6, precision = 19, nullable = false)
private BigDecimal requiredTotal; private BigDecimal requiredTotal;
@@ -72,4 +72,12 @@ public class InvestmentValuationLine extends AbstractPersistableCustom<Long> {
/** Market value less book value: a loss when negative. */ /** Market value less book value: a loss when negative. */
@Column(name = "difference", scale = 6, precision = 19, nullable = false) @Column(name = "difference", scale = 6, precision = 19, nullable = false)
private BigDecimal difference; private BigDecimal difference;
/** How a share was valued (face value, nil, ₹1); empty for a holding valued at its market rate. */
@Column(name = "valuation_basis", length = 100)
private String valuationBasis;
/** A non-performing investment: shares of an institution whose latest balance sheet is over 18 months old. */
@Column(name = "is_npi", nullable = false)
private boolean npi;
} }
@@ -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.InvestmentShareService;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "INVESTMENTPURCHASE", action = "DIVIDEND")
@RequiredArgsConstructor
public class DividendInvestmentPurchaseCommandHandler implements NewCommandSourceHandler {
private final InvestmentShareService shareService;
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.shareService.postDividend(command.entityId(), command);
}
}
@@ -36,8 +36,11 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INSTITUTION_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INSTITUTION_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INTEREST_GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INTEREST_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LAST_BALANCE_SHEET_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
@@ -59,6 +62,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SUBSCRIBED_CAPITAL;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
@@ -77,6 +81,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
/** Validates Bank / Party Master and Script Master requests before anything is written. */ /** Validates Bank / Party Master and Script Master requests before anything is written. */
@@ -133,6 +138,21 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer {
final BigDecimal rate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_RATE, element); final BigDecimal rate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS_RATE, element);
v.reset().parameter(TDS_RATE).value(rate).ignoreIfNull().positiveAmount().notGreaterThanMax(BigDecimal.valueOf(100)); v.reset().parameter(TDS_RATE).value(rate).ignoreIfNull().positiveAmount().notGreaterThanMax(BigDecimal.valueOf(100));
} }
final String institutionType = this.fromApiJsonHelper.extractStringNamed(INSTITUTION_TYPE, element);
if (StringUtils.isNotBlank(institutionType)) {
v.reset().parameter(INSTITUTION_TYPE).value(institutionType).isOneOfTheseValues(INSTITUTION_TYPES.toArray());
}
if (this.fromApiJsonHelper.parameterExists(SUBSCRIBED_CAPITAL, element)) {
final BigDecimal capital = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(SUBSCRIBED_CAPITAL, element);
v.reset().parameter(SUBSCRIBED_CAPITAL).value(capital).ignoreIfNull().positiveAmount();
}
if (this.fromApiJsonHelper.parameterExists(LAST_BALANCE_SHEET_DATE, element)) {
final LocalDate balanceSheet = this.fromApiJsonHelper.extractLocalDateNamed(LAST_BALANCE_SHEET_DATE, element);
if (balanceSheet != null && balanceSheet.isAfter(DateUtils.getBusinessLocalDate())) {
v.reset().parameter(LAST_BALANCE_SHEET_DATE).failWithCodeAndMessage("in.future",
"The balance sheet date cannot be after today");
}
}
throwIfErrors(errors); throwIfErrors(errors);
} }
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConsta
import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RD_DEPOSIT_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RD_DEPOSIT_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.REMARKS; import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.REMARKS;
import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_ACTIVE; import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.OWNED_FUNDS;
import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TOTAL_DEPOSITS; import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TOTAL_DEPOSITS;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
@@ -145,7 +146,9 @@ public class InvestmentFdService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public InvestmentFdLimitData limits(final LocalDate dealDate, final InvestmentParty party, final BigDecimal amount) { public InvestmentFdLimitData limits(final LocalDate dealDate, final InvestmentParty party, final BigDecimal amount) {
final LocalDate base = previousYearEnd(dealDate); final LocalDate base = previousYearEnd(dealDate);
final BigDecimal total = this.depositBaseRepository.findByAsOnDate(base).map(InvestmentDepositBase::getTotalDeposits).orElse(null); final InvestmentDepositBase depositBase = this.depositBaseRepository.findByAsOnDate(base).orElse(null);
final BigDecimal total = depositBase == null ? null : depositBase.getTotalDeposits();
final BigDecimal ownedFunds = depositBase == null ? null : depositBase.getOwnedFunds();
final BigDecimal newAmount = amount == null ? BigDecimal.ZERO : amount; final BigDecimal newAmount = amount == null ? BigDecimal.ZERO : amount;
final BigDecimal fdOutstanding = this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE).stream().map(InvestmentFd::getPrincipal) final BigDecimal fdOutstanding = this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE).stream().map(InvestmentFd::getPrincipal)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
@@ -156,7 +159,7 @@ public class InvestmentFdService {
final BigDecimal grossAfter = fdOutstanding.add(bankBalances).add(newAmount); final BigDecimal grossAfter = fdOutstanding.add(bankBalances).add(newAmount);
final BigDecimal bankAfter = withParty.add(newAmount); final BigDecimal bankAfter = withParty.add(newAmount);
final InvestmentFdLimitData.InvestmentFdLimitDataBuilder b = InvestmentFdLimitData.builder().asOnDate(base.format(DATE)) final InvestmentFdLimitData.InvestmentFdLimitDataBuilder b = InvestmentFdLimitData.builder().asOnDate(base.format(DATE))
.totalDeposits(total).glTotalDeposits(glDeposits(base)).fdOutstanding(fdOutstanding).bankBalances(bankBalances) .totalDeposits(total).ownedFunds(ownedFunds).glTotalDeposits(glDeposits(base)).fdOutstanding(fdOutstanding).bankBalances(bankBalances)
.grossAfter(grossAfter).partyName(party == null ? null : party.getPartyName()).bankAfter(bankAfter).newAmount(newAmount); .grossAfter(grossAfter).partyName(party == null ? null : party.getPartyName()).bankAfter(bankAfter).newAmount(newAmount);
if (total == null) { if (total == null) {
return b.withinLimits(false).message("Enter the bank's total deposits as on " + base.format(DATE) return b.withinLimits(false).message("Enter the bank's total deposits as on " + base.format(DATE)
@@ -257,7 +260,9 @@ public class InvestmentFdService {
validate(command.json(), BASE_PARAMS, v -> { validate(command.json(), BASE_PARAMS, v -> {
final JsonElement e = this.fromApiJsonHelper.parse(command.json()); final JsonElement e = this.fromApiJsonHelper.parse(command.json());
v.reset().parameter(AS_ON_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(AS_ON_DATE, e)).notNull(); v.reset().parameter(AS_ON_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(AS_ON_DATE, e)).notNull();
v.reset().parameter(TOTAL_DEPOSITS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TOTAL_DEPOSITS, e)).notNull() v.reset().parameter(TOTAL_DEPOSITS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TOTAL_DEPOSITS, e))
.ignoreIfNull().positiveAmount();
v.reset().parameter(OWNED_FUNDS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(OWNED_FUNDS, e)).ignoreIfNull()
.positiveAmount(); .positiveAmount();
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, e)).ignoreIfNull().notExceedingLengthOf(500); v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, e)).ignoreIfNull().notExceedingLengthOf(500);
}); });
@@ -267,7 +272,16 @@ public class InvestmentFdService {
} }
final InvestmentDepositBase base = this.depositBaseRepository.findByAsOnDate(asOn).orElseGet(InvestmentDepositBase::new); final InvestmentDepositBase base = this.depositBaseRepository.findByAsOnDate(asOn).orElseGet(InvestmentDepositBase::new);
base.setAsOnDate(asOn); base.setAsOnDate(asOn);
base.setTotalDeposits(command.bigDecimalValueOfParameterNamed(TOTAL_DEPOSITS)); // Owned funds may be added later (Share Entry) to a year whose total deposits are already saved.
if (command.bigDecimalValueOfParameterNamed(TOTAL_DEPOSITS) != null) {
base.setTotalDeposits(command.bigDecimalValueOfParameterNamed(TOTAL_DEPOSITS));
}
if (base.getTotalDeposits() == null) {
throw rule("base.deposits.required", "Enter the total deposits as on " + asOn.format(DATE) + " as well");
}
if (command.parameterExists(OWNED_FUNDS)) {
base.setOwnedFunds(command.bigDecimalValueOfParameterNamed(OWNED_FUNDS));
}
base.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS))); base.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS)));
final InvestmentDepositBase saved = this.depositBaseRepository.saveAndFlush(base); final InvestmentDepositBase saved = this.depositBaseRepository.saveAndFlush(base);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId())
@@ -335,6 +349,7 @@ public class InvestmentFdService {
} }
String nextDealNo(final LocalDate dealDate) { String nextDealNo(final LocalDate dealDate) {
InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_fd.deal_no");
final String prefix = InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(dealDate) + "F"; final String prefix = InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(dealDate) + "F";
final Integer max = this.jdbcTemplate.queryForObject( final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(deal_no FROM 8) AS INTEGER)) FROM m_investment_fd WHERE deal_no LIKE ?", Integer.class, "SELECT MAX(CAST(SUBSTRING(deal_no FROM 8) AS INTEGER)) FROM m_investment_fd WHERE deal_no LIKE ?", Integer.class,
@@ -68,8 +68,10 @@ public class InvestmentInterestReportService {
public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) { public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) {
final List<InvestmentInterestReportData.Row> rows = new ArrayList<>(); final List<InvestmentInterestReportData.Row> rows = new ArrayList<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
// Shares earn dividends, not interest.
if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus()) if (InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
|| InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) { || InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())
|| InvestmentApiConstants.SHARE.equals(p.getBondType())) {
continue; continue;
} }
security(p, from, to, rows); security(p, from, to, rows);
@@ -35,10 +35,14 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FACE_VALUE_PER_UNIT;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.FAX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INSTITUTION_OTHER_COOP_SOCIETY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INSTITUTION_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INTEREST_GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INTEREST_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.IN_LIQUIDATION;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LAST_BALANCE_SHEET_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LEI_NO;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LISTED; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.LISTED;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.MATURITY_DATE;
@@ -51,7 +55,9 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstant
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.RBI_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_CODE_PREFIX;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SCRIPT_DESC;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SHARE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SLR_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SUBSCRIBED_CAPITAL;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_APPLICABLE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_APPLICABLE;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_RATE;
@@ -176,6 +182,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.name.duplicate", throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.name.duplicate",
"A " + party.getBondType() + " party named " + party.getPartyName() + " already exists", party.getPartyName()); "A " + party.getBondType() + " party named " + party.getPartyName() + " already exists", party.getPartyName());
} }
requireShareInstitutionRules(party);
if (!"FD".equals(party.getBondType())) { if (!"FD".equals(party.getBondType())) {
return; return;
} }
@@ -193,6 +200,30 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
} }
} }
/**
* A share party is the institution whose shares the bank holds: its type decides the limits and the valuation, and a
* co-operative society needs its subscribed capital for the 5% limit. Other parties carry none of this.
*/
private static void requireShareInstitutionRules(final InvestmentParty party) {
if (!SHARE.equals(party.getBondType())) {
party.setInstitutionType(null);
party.setSubscribedCapital(null);
party.setLastBalanceSheetDate(null);
party.setInLiquidation(false);
return;
}
if (StringUtils.isBlank(party.getInstitutionType())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.institution.type.required",
"Choose the institution type: DCCB, State Co-op Bank, co-operative society, or NPCI / CCIL / SWIFT / "
+ "Umbrella Organisation; a UCB may not hold shares of other companies");
}
if (INSTITUTION_OTHER_COOP_SOCIETY.equals(party.getInstitutionType())
&& (party.getSubscribedCapital() == null || party.getSubscribedCapital().signum() <= 0)) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentparty.subscribed.capital.required",
"Enter the society's subscribed share capital; the bank may hold at most 5% of it");
}
}
/** Copies every party field present in the request onto the entity (absent fields are left unchanged). */ /** Copies every party field present in the request onto the entity (absent fields are left unchanged). */
private void applyParty(final InvestmentParty p, final JsonCommand command) { private void applyParty(final InvestmentParty p, final JsonCommand command) {
setIfPresent(command, BOND_TYPE, p::setBondType); setIfPresent(command, BOND_TYPE, p::setBondType);
@@ -224,6 +255,18 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID); final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID);
p.setInterestGlAccount(interestGlId == null ? null : postableIncomeGl(interestGlId)); p.setInterestGlAccount(interestGlId == null ? null : postableIncomeGl(interestGlId));
} }
if (command.parameterExists(INSTITUTION_TYPE)) {
p.setInstitutionType(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(INSTITUTION_TYPE)));
}
if (command.parameterExists(SUBSCRIBED_CAPITAL)) {
p.setSubscribedCapital(command.bigDecimalValueOfParameterNamed(SUBSCRIBED_CAPITAL));
}
if (command.parameterExists(LAST_BALANCE_SHEET_DATE)) {
p.setLastBalanceSheetDate(command.localDateValueOfParameterNamed(LAST_BALANCE_SHEET_DATE));
}
if (command.parameterExists(IN_LIQUIDATION)) {
p.setInLiquidation(command.booleanPrimitiveValueOfParameterNamed(IN_LIQUIDATION));
}
if (command.parameterExists(TDS_APPLICABLE)) { if (command.parameterExists(TDS_APPLICABLE)) {
p.setTdsApplicable(command.booleanPrimitiveValueOfParameterNamed(TDS_APPLICABLE)); p.setTdsApplicable(command.booleanPrimitiveValueOfParameterNamed(TDS_APPLICABLE));
} }
@@ -260,6 +303,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
/** Next code in the PTY-001, PTY-002 … series. */ /** Next code in the PTY-001, PTY-002 … series. */
private String nextPartyCode() { private String nextPartyCode() {
InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_party.party_code");
final Integer max = this.jdbcTemplate.queryForObject( final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(party_code FROM 5) AS INTEGER)) FROM m_investment_party WHERE party_code ~ '^PTY-[0-9]+$'", "SELECT MAX(CAST(SUBSTRING(party_code FROM 5) AS INTEGER)) FROM m_investment_party WHERE party_code ~ '^PTY-[0-9]+$'",
Integer.class); Integer.class);
@@ -423,6 +467,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste
/** Next code for the bond type — its letter plus a five-digit running number, e.g. G00001. */ /** Next code for the bond type — its letter plus a five-digit running number, e.g. G00001. */
private String nextScriptCode(final String bondType) { private String nextScriptCode(final String bondType) {
InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_script.script_code");
final String prefix = SCRIPT_CODE_PREFIX.get(bondType); final String prefix = SCRIPT_CODE_PREFIX.get(bondType);
final Integer max = this.jdbcTemplate.queryForObject( final Integer max = this.jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?", "SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?",
@@ -115,7 +115,8 @@ public class InvestmentNonSlrService {
BigDecimal unlistedOutstanding = BigDecimal.ZERO; BigDecimal unlistedOutstanding = BigDecimal.ZERO;
BigDecimal nonSlrBase = BigDecimal.ZERO; BigDecimal nonSlrBase = BigDecimal.ZERO;
for (final InvestmentPurchase p : this.purchaseRepository.findAll()) { for (final InvestmentPurchase p : this.purchaseRepository.findAll()) {
if (!NON_SLR_BOND_TYPES.contains(p.getBondType())) { // Shares of co-operative societies are non-SLR too; membership shares (DCCB, StCB, NPCI, ...) may exceed the limit.
if (!NON_SLR_BOND_TYPES.contains(p.getBondType()) && !InvestmentShareService.societyShare(p)) {
continue; continue;
} }
final BigDecimal now = bookNow(p); final BigDecimal now = bookNow(p);
@@ -135,7 +135,8 @@ public class InvestmentPurchaseReadPlatformService {
/** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */ /** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */
public List<InvestmentInterestCalculator.ScheduleRow> scheduleRows(final InvestmentPurchase p) { public List<InvestmentInterestCalculator.ScheduleRow> scheduleRows(final InvestmentPurchase p) {
// Non-SLR (bonds, mutual funds) has no interest scheme (Jitendra Sir): its income is the gain on sale / redemption. // Non-SLR (bonds, mutual funds) has no interest scheme (Jitendra Sir): its income is the gain on sale / redemption.
if (InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType())) { // Shares earn dividends, booked when received; they have no interest schedule either.
if (InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType()) || InvestmentApiConstants.SHARE.equals(p.getBondType())) {
return List.of(); return List.of();
} }
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
@@ -61,7 +61,9 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData; import org.apache.fineract.portfolio.investment.data.InvestmentNonSlrLimitData;
import org.apache.fineract.portfolio.investment.data.InvestmentShareLimitData;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty; import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
@@ -94,6 +96,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
private final JdbcTemplate jdbcTemplate; private final JdbcTemplate jdbcTemplate;
private final InvestmentJournalPoster journalPoster; private final InvestmentJournalPoster journalPoster;
private final InvestmentNonSlrService nonSlrService; private final InvestmentNonSlrService nonSlrService;
private final InvestmentShareService shareService;
@Override @Override
@Transactional @Transactional
@@ -127,6 +130,17 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
throw rule("party.fd", "Party " + party.getPartyCode() + " " + party.getPartyName() throw rule("party.fd", "Party " + party.getPartyCode() + " " + party.getPartyName()
+ " is an FD bank; choose a dealer / counterparty party for a " + bondType + " purchase"); + " is an FD bank; choose a dealer / counterparty party for a " + bondType + " purchase");
} }
final boolean share = InvestmentApiConstants.SHARE.equals(bondType);
if (share) {
// Only shares of a DCCB / State Co-op Bank, a co-operative society or a membership institution (NPCI, CCIL, ...).
if (!InvestmentApiConstants.SHARE.equals(party.getBondType()) || StringUtils.isBlank(party.getInstitutionType())) {
throw rule("share.party", "Party " + party.getPartyCode() + " " + party.getPartyName() + " is not a share institution; "
+ "add it in Bank / Party Master with Bond Type SHARE and its institution type");
}
if (!StringUtils.startsWith(command.stringValueOfParameterNamed(CATEGORY), "AFS")) {
throw rule("share.category", "Shares are held as AFS (Available For Sale)");
}
}
final boolean nonSlr = NON_SLR_BOND_TYPES.contains(bondType); final boolean nonSlr = NON_SLR_BOND_TYPES.contains(bondType);
if (nonSlr) { if (nonSlr) {
this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)); this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE));
@@ -182,11 +196,20 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
computeMutualFund(p, command); computeMutualFund(p, command);
} else if (nonSlr) { } else if (nonSlr) {
computeNonSlrBond(p, command, script); computeNonSlrBond(p, command, script);
} else if (share) {
computeShare(p, command, script);
} else { } else {
computeSecurity(p, command, script); computeSecurity(p, command, script);
} }
p.setSettlementAmount(p.getBookValue().add(p.getAccruedInterest()).add(totalCharges(p))); p.setSettlementAmount(p.getBookValue().add(p.getAccruedInterest()).add(totalCharges(p)));
if (nonSlr) { if (share) {
final InvestmentShareLimitData shareLimit = this.shareService.limits(dealDate, party, p.getBookValue());
if (!shareLimit.isWithinLimits()) {
throw rule("share.limit.exceeded", shareLimit.getMessage());
}
}
// Shares of co-operative societies are non-SLR investments too; membership shares may exceed the limits.
if (nonSlr || InvestmentShareService.societyShare(p)) {
final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue()); final InvestmentNonSlrLimitData limit = this.nonSlrService.limits(dealDate, script, p.getBookValue());
if (!limit.isWithinLimits()) { if (!limit.isWithinLimits()) {
throw rule(limit.getTotalDeposits() == null ? "nonslr.deposit.base.missing" : "nonslr.limit.exceeded", limit.getMessage()); throw rule(limit.getTotalDeposits() == null ? "nonslr.deposit.base.missing" : "nonslr.limit.exceeded", limit.getMessage());
@@ -333,6 +356,28 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setAccruedInterest(BigDecimal.ZERO); p.setAccruedInterest(BigDecimal.ZERO);
} }
/**
* Shares: face value (number of shares x face value per share) at a price per share — co-operative shares are taken at
* face value. No interest; the dividend is income when it is received.
*/
private void computeShare(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script) {
final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE);
final BigDecimal price = command.bigDecimalValueOfParameterNamed(PURCHASE_RATE);
if (faceValue.remainder(script.getFaceValuePerUnit()).signum() != 0) {
throw rule("facevalue.not.multiple", "Face value must be a whole number of shares of "
+ script.getFaceValuePerUnit().stripTrailingZeros().toPlainString() + " each of " + script.getScriptCode());
}
final BigDecimal shares = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP);
p.setFaceValue(faceValue);
p.setPurchaseRate(price);
p.setUnits(shares);
p.setBalanceUnits(shares);
p.setBookValue(shares.multiply(price).setScale(2, RoundingMode.HALF_UP));
p.setPremiumDiscount(BigDecimal.ZERO);
p.setAccruedDays(0);
p.setAccruedInterest(BigDecimal.ZERO);
}
/** Mutual fund: the amount invested buys units at the NAV. */ /** Mutual fund: the amount invested buys units at the NAV. */
private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command) { private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command) {
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT); final BigDecimal amount = command.bigDecimalValueOfParameterNamed(CONSIDER_AMT);
@@ -367,6 +412,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
* by a mutual fund switch, which opens a new deal. * by a mutual fund switch, which opens a new deal.
*/ */
static String nextDealNo(final JdbcTemplate jdbcTemplate, final LocalDate dealDate) { static String nextDealNo(final JdbcTemplate jdbcTemplate, final LocalDate dealDate) {
lockNumbering(jdbcTemplate, "m_investment_purchase.deal_no");
final String prefix = dealNoPrefix(dealDate); final String prefix = dealNoPrefix(dealDate);
final Integer max = jdbcTemplate.queryForObject( final Integer max = jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class, "SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class,
@@ -380,7 +426,16 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return fyStart + String.format("%02d", (fyStart + 1) % 100); return fyStart + String.format("%02d", (fyStart + 1) % 100);
} }
/**
* Holds a lock on a number series until the transaction ends, so that two commands approved together (the Checker
* Inbox sends a bulk approve in parallel) do not read the same MAX and take the same number.
*/
static void lockNumbering(final JdbcTemplate jdbcTemplate, final String series) {
jdbcTemplate.queryForObject("SELECT 1 FROM pg_advisory_xact_lock(hashtext(?))", Integer.class, series);
}
static String nextCertNo(final JdbcTemplate jdbcTemplate) { static String nextCertNo(final JdbcTemplate jdbcTemplate) {
lockNumbering(jdbcTemplate, "m_investment_purchase.cert_no");
final Integer max = jdbcTemplate.queryForObject( final Integer max = jdbcTemplate.queryForObject(
"SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class); "SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class);
return String.valueOf((max == null ? 0 : max) + 1); return String.valueOf((max == null ? 0 : max) + 1);
@@ -106,11 +106,13 @@ public class InvestmentSaleQuoter {
} }
final BigDecimal available = availableUnits(p, ignoreSaleId); final BigDecimal available = availableUnits(p, ignoreSaleId);
if (r.units().compareTo(available) > 0) { if (r.units().compareTo(available) > 0) {
throw rule("units.exceed", "Only " + available.stripTrailingZeros().toPlainString() + " units of deal " + p.getDealNo() final boolean shares = InvestmentApiConstants.SHARE.equals(p.getBondType());
+ " are available to sell"); throw rule("units.exceed", "Only " + available.stripTrailingZeros().toPlainString() + (shares ? " shares" : " units")
+ " of deal " + p.getDealNo() + (shares ? " are left to refund" : " are available to sell"));
} }
final boolean mutualFund = "MUTUAL_FUND".equals(p.getBondType()); final boolean mutualFund = "MUTUAL_FUND".equals(p.getBondType());
final boolean share = InvestmentApiConstants.SHARE.equals(p.getBondType());
// Non-SLR (bond, mutual fund) has no interest scheme (Jitendra Sir): the whole gain is the price difference. // Non-SLR (bond, mutual fund) has no interest scheme (Jitendra Sir): the whole gain is the price difference.
final boolean nonSlr = InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType()); final boolean nonSlr = InvestmentApiConstants.NON_SLR_BOND_TYPES.contains(p.getBondType());
final BigDecimal unitValue = p.getScript().getFaceValuePerUnit(); final BigDecimal unitValue = p.getScript().getFaceValuePerUnit();
@@ -121,6 +123,10 @@ public class InvestmentSaleQuoter {
consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP); consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP);
face = p.getUnits().signum() == 0 ? BigDecimal.ZERO face = p.getUnits().signum() == 0 ? BigDecimal.ZERO
: p.getFaceValue().multiply(r.units()).divide(p.getUnits(), 2, RoundingMode.HALF_UP); : p.getFaceValue().multiply(r.units()).divide(p.getUnits(), 2, RoundingMode.HALF_UP);
} else if (share) {
// Share capital is refunded (retired) at a price per share, usually its face value.
face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP);
consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP);
} else { } else {
face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP); face = r.units().multiply(unitValue).setScale(2, RoundingMode.HALF_UP);
consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP); consideration = face.multiply(r.saleRate()).divide(HUNDRED, 2, RoundingMode.HALF_UP);
@@ -128,7 +134,7 @@ public class InvestmentSaleQuoter {
int days = 0; int days = 0;
BigDecimal accrued = BigDecimal.ZERO; BigDecimal accrued = BigDecimal.ZERO;
BigDecimal receivable = BigDecimal.ZERO; BigDecimal receivable = BigDecimal.ZERO;
if (!nonSlr) { if (!nonSlr && !share) {
final LocalDate lastCoupon = lastCoupon(p, settlementDate); final LocalDate lastCoupon = lastCoupon(p, settlementDate);
days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate); days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate);
final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate();
@@ -174,8 +180,8 @@ public class InvestmentSaleQuoter {
public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) { public InvestmentSale quoteRedemption(final InvestmentPurchase p, final Long bankGlAccountId, final String remarks) {
requireActiveDebtHolding(p); requireActiveDebtHolding(p);
if (p.getMaturityDate() == null) { if (p.getMaturityDate() == null) {
throw rule("redeem.no.maturity", "Deal " + p.getDealNo() + " has no maturity date; redeem a mutual fund at its NAV through the " throw rule("redeem.no.maturity", "Deal " + p.getDealNo() + " has no maturity date; redeem a mutual fund at its NAV, or a "
+ "Sell / Redeem entry"); + "share refund, through the Sell / Redeem entry");
} }
if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) { if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE) throw rule("redeem.before.maturity", "Deal " + p.getDealNo() + " matures on " + p.getMaturityDate().format(DATE)
@@ -299,8 +305,9 @@ public class InvestmentSaleQuoter {
if (!STATUS_ACTIVE.equals(p.getStatus())) { if (!STATUS_ACTIVE.equals(p.getStatus())) {
throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding"); throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding");
} }
if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType()) && !"MUTUAL_FUND".equals(p.getBondType())) { if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType()) && !"MUTUAL_FUND".equals(p.getBondType())
throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Bond and Mutual Fund holdings"); && !InvestmentApiConstants.SHARE.equals(p.getBondType())) {
throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Bond, Mutual Fund and Share holdings");
} }
} }
@@ -205,7 +205,9 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
// more would leave the investment GL short when the deal is later sold or redeemed. // more would leave the investment GL short when the deal is later sold or redeemed.
final BigDecimal limit = p.getPremiumGlAccount() != null ? this.quoter.remainingBook(p).subtract(this.quoter.unamortisedPremium(p)) final BigDecimal limit = p.getPremiumGlAccount() != null ? this.quoter.remainingBook(p).subtract(this.quoter.unamortisedPremium(p))
: this.quoter.remainingBook(p); : this.quoter.remainingBook(p);
if (amount.compareTo(limit) >= 0) { // Shares of an institution in liquidation may be written off in full: they stay held at nil until the liquidator pays.
final boolean liquidatedShare = InvestmentApiConstants.SHARE.equals(p.getBondType()) && p.getParty().isInLiquidation();
if (liquidatedShare ? amount.compareTo(limit) > 0 : amount.compareTo(limit) >= 0) {
throw InvestmentSaleQuoter.rule("writeoff.exceeds", "The write-off must be less than " throw InvestmentSaleQuoter.rule("writeoff.exceeds", "The write-off must be less than "
+ limit.stripTrailingZeros().toPlainString() + " (book value less premium still to be amortised); " + limit.stripTrailingZeros().toPlainString() + " (book value less premium still to be amortised); "
+ "sell or redeem the security to close it"); + "sell or redeem the security to close it");
@@ -0,0 +1,290 @@
/**
* 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.InvestmentApiConstants.INSTITUTION_OTHER_COOP_SOCIETY;
import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.SHARE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.AMOUNT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BANK_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DIVIDEND_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DIVIDEND_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.DIVIDEND_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.REMARKS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_SOLD;
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.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.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.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.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentDividendData;
import org.apache.fineract.portfolio.investment.data.InvestmentShareLimitData;
import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBase;
import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentDividend;
import org.apache.fineract.portfolio.investment.domain.InvestmentDividendRepository;
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.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Share investments a UCB may hold: shares of its DCCB / State Co-op Bank, of co-operative societies in its area of
* operation, and membership shares of NPCI / CCIL / SWIFT, the Umbrella Organisation and IDPIC. Shares of co-operative
* societies are limited to 2% of the bank's owned funds in all and to 5% of one society's subscribed capital; membership
* shares may exceed these. Dividends are income when received.
*/
@Service
@RequiredArgsConstructor
public class InvestmentShareService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final BigDecimal OWNED_FUNDS_PERCENT = BigDecimal.valueOf(2);
private static final BigDecimal SUBSCRIBED_CAPITAL_PERCENT = BigDecimal.valueOf(5);
private final FromJsonHelper fromApiJsonHelper;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final InvestmentPartyRepository partyRepository;
private final InvestmentDepositBaseRepository depositBaseRepository;
private final InvestmentDividendRepository dividendRepository;
private final GLAccountRepositoryWrapper glAccountRepository;
private final InvestmentJournalPoster journalPoster;
/** Shares of a co-operative society: the holdings the 2% and 5% limits (and the 10% non-SLR limit) apply to. */
static boolean societyShare(final InvestmentPurchase p) {
return SHARE.equals(p.getBondType()) && p.getParty() != null
&& INSTITUTION_OTHER_COOP_SOCIETY.equals(p.getParty().getInstitutionType());
}
/** The RBI limits for a share purchase of {@code amount} from {@code partyId} (either may be empty) on {@code dealDate}. */
@Transactional(readOnly = true)
public InvestmentShareLimitData limits(final LocalDate dealDate, final Long partyId, final BigDecimal amount) {
final InvestmentParty party = partyId == null ? null
: this.partyRepository.findById(partyId).orElseThrow(() -> new InvestmentPartyNotFoundException(partyId));
return limits(dealDate, party, amount);
}
@Transactional(readOnly = true)
public InvestmentShareLimitData limits(final LocalDate dealDate, final InvestmentParty party, final BigDecimal amount) {
final LocalDate base = InvestmentFdService.previousYearEnd(dealDate);
final BigDecimal ownedFunds = this.depositBaseRepository.findByAsOnDate(base).map(InvestmentDepositBase::getOwnedFunds)
.orElse(null);
final BigDecimal newAmount = amount == null ? BigDecimal.ZERO : amount;
final boolean society = party != null && INSTITUTION_OTHER_COOP_SOCIETY.equals(party.getInstitutionType());
BigDecimal coopOutstanding = BigDecimal.ZERO;
BigDecimal withParty = BigDecimal.ZERO;
for (final InvestmentPurchase p : this.purchaseRepository.findAll()) {
if (!societyShare(p)) {
continue;
}
final BigDecimal now = bookNow(p);
coopOutstanding = coopOutstanding.add(now);
if (party != null && p.getParty().getId().equals(party.getId())) {
withParty = withParty.add(now);
}
}
final BigDecimal coopAfter = society ? coopOutstanding.add(newAmount) : coopOutstanding;
final BigDecimal institutionAfter = withParty.add(newAmount);
final BigDecimal capital = party == null ? null : party.getSubscribedCapital();
final InvestmentShareLimitData.InvestmentShareLimitDataBuilder b = InvestmentShareLimitData.builder().asOnDate(base.format(DATE))
.institutionType(party == null ? null : party.getInstitutionType()).exempt(party != null && !society)
.ownedFunds(ownedFunds).coopLimit(ownedFunds == null ? null : percent(ownedFunds, OWNED_FUNDS_PERCENT))
.coopOutstanding(coopOutstanding).coopAfter(coopAfter).partyName(party == null ? null : party.getPartyName())
.subscribedCapital(capital).institutionLimit(capital == null ? null : percent(capital, SUBSCRIBED_CAPITAL_PERCENT))
.institutionOutstanding(withParty).institutionAfter(institutionAfter).newAmount(newAmount);
if (party == null) {
return b.withinLimits(true).message("Choose the institution").build();
}
if (!society) {
return b.withinLimits(true).message("Membership shares of a " + label(party.getInstitutionType())
+ " may exceed the RBI limits on shares of co-operative societies").build();
}
if (ownedFunds == null) {
return b.withinLimits(false).message("Enter the bank's owned funds (paid-up share capital + reserves) as on "
+ base.format(DATE) + " with the deposit base first; shares of co-operative societies are limited to 2% of it")
.build();
}
final BigDecimal coopLimit = percent(ownedFunds, OWNED_FUNDS_PERCENT);
if (coopAfter.compareTo(coopLimit) > 0) {
return b.withinLimits(false).message("Shares of co-operative societies would be " + InvestmentNonSlrService.plain(coopAfter)
+ ", above the RBI limit of " + InvestmentNonSlrService.plain(coopLimit) + " (2% of owned funds "
+ InvestmentNonSlrService.plain(ownedFunds) + " as on " + base.format(DATE) + ")").build();
}
if (capital == null) {
return b.withinLimits(false).message("Enter the subscribed share capital of " + party.getPartyName()
+ " in Bank / Party Master first; the bank may hold at most 5% of it").build();
}
final BigDecimal institutionLimit = percent(capital, SUBSCRIBED_CAPITAL_PERCENT);
if (institutionAfter.compareTo(institutionLimit) > 0) {
return b.withinLimits(false).message("Shares of " + party.getPartyName() + " would be "
+ InvestmentNonSlrService.plain(institutionAfter) + ", above the RBI limit of " + InvestmentNonSlrService.plain(institutionLimit)
+ " (5% of its subscribed capital " + InvestmentNonSlrService.plain(capital) + ")").build();
}
return b.withinLimits(true).message("Within the RBI limits").build();
}
/**
* Records a dividend received on a share holding: Dr the bank GL / Cr 400404 Dividend on Investments, dated the day it
* was received, with a voucher. Income is taken when it is received; nothing is accrued.
*/
@Transactional
public CommandProcessingResult postDividend(final Long purchaseId, final JsonCommand command) {
validateDividend(command.json());
final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (!SHARE.equals(p.getBondType())) {
throw rule("not.share", "Deal " + p.getDealNo() + " is not a share holding; a dividend is entered on shares only");
}
// A dividend for a year the shares were held can come in after they were refunded.
if (!STATUS_ACTIVE.equals(p.getStatus()) && !STATUS_SOLD.equals(p.getStatus())) {
throw rule("not.held", "Deal " + p.getDealNo() + " is not a settled holding");
}
final LocalDate date = command.localDateValueOfParameterNamed(DIVIDEND_DATE);
if (date.isAfter(DateUtils.getBusinessLocalDate())) {
throw rule("date.future", "The dividend date cannot be after today");
}
if (date.isBefore(p.getSettlementDate())) {
throw rule("date.before.purchase", "The dividend date cannot be before the shares were bought (settled "
+ p.getSettlementDate().format(DATE) + ")");
}
final BigDecimal amount = command.bigDecimalValueOfParameterNamed(AMOUNT).setScale(2, RoundingMode.HALF_UP);
final GLAccount bank = command.parameterExists(BANK_GL_ACCOUNT_ID) && command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) != null
? postableAssetGl(command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID))
: p.getCrGlAccount();
final CommandProcessingResult posted = this.journalPoster.post(date, p.getDealNo(),
"Dividend received: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc()
+ ", " + p.getParty().getPartyName(),
new InvestmentJournalPoster.Lines().debit(bank.getId(), amount).credit(this.journalPoster.glId(DIVIDEND_GL_CODE), amount));
final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo");
final InvestmentDividend d = new InvestmentDividend();
d.setPurchase(p);
d.setDividendDate(date);
d.setAmount(amount);
d.setBankGlAccount(bank);
d.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS)));
d.setGlTransactionId(posted.getTransactionId());
d.setVoucherNo(voucherNo == null ? null : voucherNo.toString());
final InvestmentDividend saved = this.dividendRepository.saveAndFlush(d);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).withSubEntityId(purchaseId)
.withResourceIdAsString(saved.getVoucherNo()).withTransactionId(posted.getTransactionId()).build();
}
/** Dividends received, newest first. */
@Transactional(readOnly = true)
public List<InvestmentDividendData> retrieveDividends() {
return this.dividendRepository.findAllByOrderByDividendDateDescIdDesc().stream().map(InvestmentDividendData::from).toList();
}
/** Book value of a share deal now: cost less settled refunds and write-offs; a pending deal counts at its full cost. */
private BigDecimal bookNow(final InvestmentPurchase p) {
if (STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
return p.getBookValue();
}
if (!STATUS_ACTIVE.equals(p.getStatus())) {
return BigDecimal.ZERO;
}
BigDecimal book = p.getBookValue();
for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) {
if (InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus()) && s.getBookValueSold() != null) {
book = book.subtract(s.getBookValueSold());
}
}
return book;
}
private static String label(final String institutionType) {
return switch (institutionType == null ? "" : institutionType) {
case "STATE_COOP_BANK" -> "State Co-op Bank";
case "DCCB" -> "District Central Co-op Bank";
case "MEMBERSHIP_MIC_UO" -> "market infrastructure company / Umbrella Organisation";
default -> "co-operative institution";
};
}
private static BigDecimal percent(final BigDecimal base, final BigDecimal percent) {
return base.multiply(percent).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP);
}
/** The bank the dividend 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;
}
private void validateDividend(final String json) {
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DIVIDEND_PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource("investmentdividend");
v.reset().parameter(DIVIDEND_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DIVIDEND_DATE, element)).notNull();
v.reset().parameter(AMOUNT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AMOUNT, element)).notNull()
.positiveAmount();
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);
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.investmentshare." + code, message);
}
}
@@ -23,6 +23,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentValuationAp
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_BOND; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_BOND;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_FUND; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_FUND;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_GOVT; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_GOVT;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_SHARE;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_PROVISION_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_PROVISION_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_RESERVE_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_RESERVE_GL_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.RUN_PARAMS; import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.RUN_PARAMS;
@@ -36,10 +37,12 @@ import java.math.RoundingMode;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
@@ -52,11 +55,14 @@ import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants; import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentValuationData; import org.apache.fineract.portfolio.investment.data.InvestmentValuationData;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentDividendRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentParty;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
import org.apache.fineract.portfolio.investment.domain.InvestmentSale; import org.apache.fineract.portfolio.investment.domain.InvestmentSale;
@@ -77,8 +83,9 @@ import org.springframework.transaction.annotation.Transactional;
* Market valuation (MTM) of AFS and HFT holdings (RBI: HFT monthly, AFS quarterly, or more often; HTM is carried at cost). * Market valuation (MTM) of AFS and HFT holdings (RBI: HFT monthly, AFS quarterly, or more often; HTM is carried at cost).
* Each security still held on the valuation date is valued at the latest Daily Script Master rate on or before that date * Each security still held on the valuation date is valued at the latest Daily Script Master rate on or before that date
* — a bond or Govt security at units x unit value x rate / 100, a mutual fund at units x NAV — against the book value * — a bond or Govt security at units x unit value x rate / 100, a mutual fund at units x NAV — against the book value
* still held. Gains and losses are netted within each class (Govt securities, corporate bonds, mutual funds); a net loss * still held. Shares have no market rate and are valued by rule (see {@link #shareLine}). Gains and losses are netted
* is the IDR the class needs, a net gain is ignored, and one class never offsets another. The book value does not * within each class (Govt securities, corporate bonds, mutual funds, shares); a net loss is the IDR the class needs, a net
* gain is ignored, and one class never offsets another. The book value does not
* change: the run posts only the difference from the IDR the category's previous run held — Dr 500504 Provision for * change: the run posts only the difference from the IDR the category's previous run held — Dr 500504 Provision for
* Depreciation / Cr 200205 Investment Depreciation Reserve when it grows, the reverse (a write-back) when it falls. * Depreciation / Cr 200205 Investment Depreciation Reserve when it grows, the reverse (a write-back) when it falls.
*/ */
@@ -88,13 +95,14 @@ public class InvestmentValuationService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); private static final BigDecimal HUNDRED = BigDecimal.valueOf(100);
private static final List<String> VALUED_BOND_TYPES = List.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND"); private static final List<String> VALUED_BOND_TYPES = List.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "SHARE");
private final FromJsonHelper fromApiJsonHelper; private final FromJsonHelper fromApiJsonHelper;
private final InvestmentPurchaseRepository purchaseRepository; private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository; private final InvestmentSaleRepository saleRepository;
private final InvestmentSecurityTransferRepository transferRepository; private final InvestmentSecurityTransferRepository transferRepository;
private final InvestmentDailyRateRepository dailyRateRepository; private final InvestmentDailyRateRepository dailyRateRepository;
private final InvestmentDividendRepository dividendRepository;
private final InvestmentValuationRepository valuationRepository; private final InvestmentValuationRepository valuationRepository;
private final InvestmentValuationLineRepository lineRepository; private final InvestmentValuationLineRepository lineRepository;
private final InvestmentJournalPoster journalPoster; private final InvestmentJournalPoster journalPoster;
@@ -156,6 +164,7 @@ public class InvestmentValuationService {
v.setGovtRequired(required.get(CLASS_GOVT)); v.setGovtRequired(required.get(CLASS_GOVT));
v.setBondRequired(required.get(CLASS_BOND)); v.setBondRequired(required.get(CLASS_BOND));
v.setFundRequired(required.get(CLASS_FUND)); v.setFundRequired(required.get(CLASS_FUND));
v.setShareRequired(required.get(CLASS_SHARE));
v.setRequiredTotal(total); v.setRequiredTotal(total);
v.setPreviousTotal(previous); v.setPreviousTotal(previous);
v.setDelta(delta); v.setDelta(delta);
@@ -208,6 +217,7 @@ public class InvestmentValuationService {
private Valued value(final LocalDate valuationDate, final String category) { private Valued value(final LocalDate valuationDate, final String category) {
final List<InvestmentValuationLine> lines = new ArrayList<>(); final List<InvestmentValuationLine> lines = new ArrayList<>();
final List<String> missing = new ArrayList<>(); final List<String> missing = new ArrayList<>();
final Set<Long> rupeeTaken = new HashSet<>();
for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) {
if (!VALUED_BOND_TYPES.contains(p.getBondType()) || p.getCategory() == null || !p.getCategory().startsWith(category) if (!VALUED_BOND_TYPES.contains(p.getBondType()) || p.getCategory() == null || !p.getCategory().startsWith(category)
|| InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus()) || InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus())
@@ -230,6 +240,10 @@ public class InvestmentValuationService {
if (units.signum() <= 0) { if (units.signum() <= 0) {
continue; continue;
} }
if (InvestmentApiConstants.SHARE.equals(p.getBondType())) {
lines.add(shareLine(p, units, book.setScale(2, RoundingMode.HALF_UP), valuationDate, rupeeTaken));
continue;
}
// A holding transferred after the valuation date was still the old security on that date. // A holding transferred after the valuation date was still the old security on that date.
final InvestmentScript script = this.transferRepository.findByPurchaseIdOrderByTransferDateAscIdAsc(p.getId()).stream() final InvestmentScript script = this.transferRepository.findByPurchaseIdOrderByTransferDateAscIdAsc(p.getId()).stream()
.filter(t -> t.getTransferDate().isAfter(valuationDate)).findFirst().map(InvestmentSecurityTransfer::getFromScript) .filter(t -> t.getTransferDate().isAfter(valuationDate)).findFirst().map(InvestmentSecurityTransfer::getFromScript)
@@ -276,10 +290,53 @@ public class InvestmentValuationService {
return "MUTUAL_FUND".equals(bondType) ? CLASS_FUND : CLASS_BOND; return "MUTUAL_FUND".equals(bondType) ? CLASS_FUND : CLASS_BOND;
} }
/**
* A share is valued by rule, not at a market rate. Shares of a co-operative institution (DCCB, State Co-op Bank,
* society) are held at face value while it pays us dividends; at nil (fully provided) when it is in liquidation or has
* paid us no dividend; at ₹1 for the institution, and as a non-performing investment, when its latest balance sheet
* is over 18 months old. Membership shares of NPCI / CCIL / SWIFT / the Umbrella Organisation stay at face value.
*/
private InvestmentValuationLine shareLine(final InvestmentPurchase p, final BigDecimal units, final BigDecimal book,
final LocalDate valuationDate, final Set<Long> rupeeTaken) {
final InvestmentParty party = p.getParty();
BigDecimal market = units.multiply(p.getScript().getFaceValuePerUnit()).setScale(2, RoundingMode.HALF_UP);
String basis = "Face value";
boolean npi = false;
if (InvestmentApiConstants.COOP_INSTITUTION_TYPES.contains(party.getInstitutionType())) {
npi = party.getLastBalanceSheetDate() == null || party.getLastBalanceSheetDate().isBefore(valuationDate.minusMonths(18));
if (party.isInLiquidation()) {
market = BigDecimal.ZERO.setScale(2);
basis = "In liquidation - fully provided";
} else if (!this.dividendRepository.existsByPurchasePartyIdAndDividendDateLessThanEqual(party.getId(), valuationDate)) {
market = BigDecimal.ZERO.setScale(2);
basis = "No dividend received - fully provided";
} else if (npi) {
// Rs 1 for the institution as a whole, not for each of our holdings of its shares.
market = (rupeeTaken.add(party.getId()) ? BigDecimal.ONE : BigDecimal.ZERO).setScale(2);
basis = "Balance sheet over 18 months old - Rs 1";
} else {
basis = "Face value (dividend received)";
}
}
final InvestmentValuationLine line = new InvestmentValuationLine();
line.setPurchase(p);
line.setScript(p.getScript());
line.setAssetClass(CLASS_SHARE);
line.setUnits(units);
line.setBookValue(book);
line.setRate(units.signum() == 0 ? BigDecimal.ZERO : market.divide(units, 6, RoundingMode.HALF_UP));
line.setRateDate(valuationDate);
line.setMarketValue(market);
line.setDifference(market.subtract(book));
line.setValuationBasis(basis);
line.setNpi(npi);
return line;
}
/** IDR each class needs: its net loss; a net gain needs none. */ /** IDR each class needs: its net loss; a net gain needs none. */
private static Map<String, BigDecimal> required(final List<InvestmentValuationLine> lines) { private static Map<String, BigDecimal> required(final List<InvestmentValuationLine> lines) {
final Map<String, BigDecimal> required = new LinkedHashMap<>(); final Map<String, BigDecimal> required = new LinkedHashMap<>();
for (final String c : List.of(CLASS_GOVT, CLASS_BOND, CLASS_FUND)) { for (final String c : List.of(CLASS_GOVT, CLASS_BOND, CLASS_FUND, CLASS_SHARE)) {
final BigDecimal net = lines.stream().filter(l -> c.equals(l.getAssetClass())).map(InvestmentValuationLine::getDifference) final BigDecimal net = lines.stream().filter(l -> c.equals(l.getAssetClass())).map(InvestmentValuationLine::getDifference)
.reduce(BigDecimal.ZERO, BigDecimal::add); .reduce(BigDecimal.ZERO, BigDecimal::add);
required.put(c, net.signum() < 0 ? net.negate() : BigDecimal.ZERO); required.put(c, net.signum() < 0 ? net.negate() : BigDecimal.ZERO);
@@ -309,6 +366,7 @@ public class InvestmentValuationService {
required.put(CLASS_GOVT, v.getGovtRequired()); required.put(CLASS_GOVT, v.getGovtRequired());
required.put(CLASS_BOND, v.getBondRequired()); required.put(CLASS_BOND, v.getBondRequired());
required.put(CLASS_FUND, v.getFundRequired()); required.put(CLASS_FUND, v.getFundRequired());
required.put(CLASS_SHARE, v.getShareRequired());
return InvestmentValuationData.builder().id(v.getId()).valuationDate(v.getValuationDate().format(DATE)).category(v.getCategory()) return InvestmentValuationData.builder().id(v.getId()).valuationDate(v.getValuationDate().format(DATE)).category(v.getCategory())
.requiredTotal(v.getRequiredTotal()).previousTotal(v.getPreviousTotal()).delta(v.getDelta()) .requiredTotal(v.getRequiredTotal()).previousTotal(v.getPreviousTotal()).delta(v.getDelta())
.provisionGlCode(DEPRECIATION_PROVISION_GL_CODE).reserveGlCode(DEPRECIATION_RESERVE_GL_CODE) .provisionGlCode(DEPRECIATION_PROVISION_GL_CODE).reserveGlCode(DEPRECIATION_RESERVE_GL_CODE)
@@ -321,7 +379,8 @@ public class InvestmentValuationService {
return InvestmentValuationData.Line.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()) return InvestmentValuationData.Line.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.bondType(p.getBondType()).scriptCode(l.getScript().getScriptCode()).scriptDesc(l.getScript().getScriptDesc()) .bondType(p.getBondType()).scriptCode(l.getScript().getScriptCode()).scriptDesc(l.getScript().getScriptDesc())
.assetClass(l.getAssetClass()).units(l.getUnits()).bookValue(l.getBookValue()).rate(l.getRate()) .assetClass(l.getAssetClass()).units(l.getUnits()).bookValue(l.getBookValue()).rate(l.getRate())
.rateDate(l.getRateDate().format(DATE)).marketValue(l.getMarketValue()).difference(l.getDifference()).build(); .rateDate(l.getRateDate().format(DATE)).marketValue(l.getMarketValue()).difference(l.getDifference())
.valuationBasis(l.getValuationBasis()).npi(l.isNpi()).build();
} }
private void validate(final String json) { private void validate(final String json) {
@@ -203,4 +203,5 @@
<include file="parts/0609_investment_script_rating.xml" relativeToChangelogFile="true"/> <include file="parts/0609_investment_script_rating.xml" relativeToChangelogFile="true"/>
<include file="parts/0610_investment_valuation_and_fund_switch.xml" relativeToChangelogFile="true"/> <include file="parts/0610_investment_valuation_and_fund_switch.xml" relativeToChangelogFile="true"/>
<include file="parts/0611_investment_maker_checker.xml" relativeToChangelogFile="true"/> <include file="parts/0611_investment_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0612_investment_share.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,109 @@
<?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">
<!--
Share investments a UCB may hold: shares of its DCCB / State Co-op Bank, of co-operative societies in its area of
operation, and membership shares of market infrastructure companies (NPCI, CCIL, SWIFT), the Umbrella Organisation
and IDPIC. The institution (party) carries its type, its subscribed capital (for the 5% per-institution limit), the
date of its latest balance sheet and whether it is in liquidation (both decide its valuation). The bank's owned
funds as on 31 March sit next to the deposit base (for the 2% limit on co-operative shares). Dividends received are
recorded with their voucher; a valuation keeps a separate class for shares.
-->
<changeSet author="cbs-anshul" id="0612-1-investment-party-share-fields">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_party" columnName="institution_type"/></not>
</preConditions>
<addColumn tableName="m_investment_party">
<column name="institution_type" type="VARCHAR(30)"/>
<column name="subscribed_capital" type="DECIMAL(19,6)"/>
<column name="last_balance_sheet_date" type="DATE"/>
<column name="is_in_liquidation" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<changeSet author="cbs-anshul" id="0612-2-investment-deposit-base-owned-funds">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_deposit_base" columnName="owned_funds"/></not>
</preConditions>
<addColumn tableName="m_investment_deposit_base">
<column name="owned_funds" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
<changeSet author="cbs-anshul" id="0612-3-create-investment-dividend">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_investment_dividend"/></not>
</preConditions>
<createTable tableName="m_investment_dividend">
<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="dividend_date" type="DATE"><constraints nullable="false"/></column>
<column name="amount" type="DECIMAL(19,6)"><constraints nullable="false"/></column>
<column name="bank_gl_account_id" type="BIGINT"><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_dividend" baseColumnNames="purchase_id" constraintName="fk_inv_dividend_purchase"
referencedTableName="m_investment_purchase" referencedColumnNames="id"/>
<addForeignKeyConstraint baseTableName="m_investment_dividend" baseColumnNames="bank_gl_account_id" constraintName="fk_inv_dividend_bank_gl"
referencedTableName="acc_gl_account" referencedColumnNames="id"/>
</changeSet>
<changeSet author="cbs-anshul" id="0612-4-investment-valuation-share-class">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_valuation" columnName="share_required"/></not>
</preConditions>
<addColumn tableName="m_investment_valuation">
<column name="share_required" type="DECIMAL(19,6)" defaultValueNumeric="0"><constraints nullable="false"/></column>
</addColumn>
<addColumn tableName="m_investment_valuation_line">
<column name="valuation_basis" type="VARCHAR(100)"/>
<column name="is_npi" type="BOOLEAN" defaultValueBoolean="false"><constraints nullable="false"/></column>
</addColumn>
</changeSet>
<!-- A dividend voucher goes to the Checker Inbox like every other investment entry. -->
<changeSet author="cbs-anshul" id="0612-5-investment-dividend-permissions">
<preConditions onFail="MARK_RAN">
<sqlCheck expectedResult="0">SELECT COUNT(*) FROM m_permission WHERE code = 'DIVIDEND_INVESTMENTPURCHASE'</sqlCheck>
</preConditions>
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES
('portfolio', 'DIVIDEND_INVESTMENTPURCHASE', 'INVESTMENTPURCHASE', 'DIVIDEND', TRUE),
('portfolio', 'DIVIDEND_INVESTMENTPURCHASE_CHECKER', 'INVESTMENTPURCHASE', 'DIVIDEND_CHECKER', FALSE)
</sql>
</changeSet>
<changeSet author="cbs-anshul" id="0612-6-grant-investment-dividend-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 = 'DIVIDEND_INVESTMENTPURCHASE'
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>