diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index d74b7d64a..9ed75588f 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -263,6 +263,14 @@ public class CommandWrapperBuilder { 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() { this.actionName = "CREATE"; this.entityName = "INVESTMENTVALUATION"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java index bde629b5d..acad2cacb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java @@ -71,6 +71,19 @@ public final class InvestmentApiConstants { /** RBI: only debt and money-market mutual funds; their sub types start with this. */ 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 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 COOP_INSTITUTION_TYPES = Set.of(INSTITUTION_STATE_COOP_BANK, INSTITUTION_DCCB, + INSTITUTION_OTHER_COOP_SOCIETY); + public static final String PARTY_CODE_PREFIX = "PTY-"; // Common parameters @@ -100,6 +113,10 @@ public final class InvestmentApiConstants { public static final String CP_IFSC = "cpIfsc"; public static final String TDS_APPLICABLE = "tdsApplicable"; 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 public static final String SLR_TYPE = "slrType"; @@ -117,7 +134,7 @@ public final class InvestmentApiConstants { public static final Set 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, - 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 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiConstants.java index da5788aaf..02320b230 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiConstants.java @@ -70,6 +70,7 @@ public final class InvestmentFdApiConstants { public static final String REMARKS = "remarks"; public static final String AS_ON_DATE = "asOnDate"; public static final String TOTAL_DEPOSITS = "totalDeposits"; + public static final String OWNED_FUNDS = "ownedFunds"; public static final String LOCALE = "locale"; public static final String DATE_FORMAT = "dateFormat"; @@ -115,5 +116,5 @@ public final class InvestmentFdApiConstants { public static final Set 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); - public static final Set BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, REMARKS, LOCALE, DATE_FORMAT); + public static final Set BASE_PARAMS = Set.of(AS_ON_DATE, TOTAL_DEPOSITS, OWNED_FUNDS, REMARKS, LOCALE, DATE_FORMAT); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java index 4610d818b..486b62ed8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java @@ -73,4 +73,12 @@ public final class InvestmentPurchaseApiConstants { public static final Set 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, 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 DIVIDEND_PARAMS = Set.of(DIVIDEND_DATE, AMOUNT, BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java index c990958ba..a9e23e50c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java @@ -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.service.DateUtils; 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.InvestmentNonSlrLimitData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; 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.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.investment.service.InvestmentSaleReadPlatformService; +import org.apache.fineract.portfolio.investment.service.InvestmentShareService; import org.springframework.stereotype.Component; @Path("/v1/investment/purchases") @@ -61,6 +64,7 @@ public class InvestmentPurchaseApiResource { private final InvestmentSaleReadPlatformService saleReadPlatformService; private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; private final InvestmentNonSlrService nonSlrService; + private final InvestmentShareService shareService; @GET @Produces({ MediaType.APPLICATION_JSON }) @@ -83,6 +87,29 @@ public class InvestmentPurchaseApiResource { 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 retrieveDividends() { + this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY); + return this.shareService.retrieveDividends(); + } + @GET @Path("{purchaseId}") @Produces({ MediaType.APPLICATION_JSON }) @@ -176,6 +203,19 @@ public class InvestmentPurchaseApiResource { 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 @Path("{purchaseId}/transfer") @Consumes({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java index 569372738..f6a3753de 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java @@ -35,6 +35,8 @@ public final class InvestmentValuationApiConstants { public static final String CLASS_GOVT = "GOVT_SECURITY"; public static final String CLASS_BOND = "CORPORATE_BOND"; 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 & Contingencies) and the reserve it is held in. */ public static final String DEPRECIATION_PROVISION_GL_CODE = "500504"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDividendData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDividendData.java new file mode 100644 index 000000000..be08a8458 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDividendData.java @@ -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(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdLimitData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdLimitData.java index 0ff1b0289..989efab04 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdLimitData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdLimitData.java @@ -31,6 +31,8 @@ public class InvestmentFdLimitData { private final String asOnDate; /** Total deposits as on that date, as entered; empty until someone enters it. */ 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. */ private final BigDecimal glTotalDeposits; private final BigDecimal grossLimit; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPartyData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPartyData.java index 8a5c7f047..49796bf96 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPartyData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPartyData.java @@ -19,6 +19,7 @@ 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.InvestmentParty; @@ -54,6 +55,10 @@ public class InvestmentPartyData { private final String cpBranchName; private final String cpAccountNo; private final String cpIfsc; + private final String institutionType; + private final BigDecimal subscribedCapital; + private final String lastBalanceSheetDate; + private final boolean inLiquidation; private final boolean active; public static InvestmentPartyData from(final InvestmentParty p) { @@ -66,6 +71,10 @@ public class InvestmentPartyData { .interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName()) .tdsApplicable(p.isTdsApplicable()).tdsRate(p.getTdsRate()).rbiCode(p.getRbiCode()).limit(p.getExposureLimit()) .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(); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentShareLimitData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentShareLimitData.java new file mode 100644 index 000000000..76814bf0f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentShareLimitData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java index 5691e2116..eb90c54d3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java @@ -81,5 +81,8 @@ public class InvestmentValuationData { private final String rateDate; private final BigDecimal marketValue; 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; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBase.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBase.java index 7ccc685e8..ff5a5357d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBase.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBase.java @@ -42,6 +42,10 @@ public class InvestmentDepositBase extends AbstractAuditableWithUTCDateTimeCusto @Column(name = "total_deposits", scale = 6, precision = 19, nullable = false) 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) private String remarks; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividend.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividend.java new file mode 100644 index 000000000..9cc855cf2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividend.java @@ -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 { + + @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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividendRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividendRepository.java new file mode 100644 index 000000000..8ca9bccb0 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDividendRepository.java @@ -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 { + + List 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentParty.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentParty.java index 043561d9e..701763a25 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentParty.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentParty.java @@ -24,6 +24,7 @@ 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; @@ -112,6 +113,22 @@ public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom { /** Market value less book value: a loss when negative. */ @Column(name = "difference", scale = 6, precision = 19, nullable = false) 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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/DividendInvestmentPurchaseCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/DividendInvestmentPurchaseCommandHandler.java new file mode 100644 index 000000000..f1e33f984 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/DividendInvestmentPurchaseCommandHandler.java @@ -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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentMasterCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentMasterCommandFromApiJsonDeserializer.java index 68c73db5f..af46d33b3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentMasterCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentMasterCommandFromApiJsonDeserializer.java @@ -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.FAX; 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.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.ISSUE_DATE; 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.SLR_TYPE; 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 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.PlatformApiDataValidationException; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.springframework.stereotype.Component; /** 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); 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdService.java index 1da9b1fea..6c9ab22c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdService.java @@ -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.REMARKS; 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 com.google.gson.JsonElement; @@ -145,7 +146,9 @@ public class InvestmentFdService { @Transactional(readOnly = true) public InvestmentFdLimitData limits(final LocalDate dealDate, final InvestmentParty party, final BigDecimal amount) { 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 fdOutstanding = this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE).stream().map(InvestmentFd::getPrincipal) .reduce(BigDecimal.ZERO, BigDecimal::add); @@ -156,7 +159,7 @@ public class InvestmentFdService { final BigDecimal grossAfter = fdOutstanding.add(bankBalances).add(newAmount); final BigDecimal bankAfter = withParty.add(newAmount); 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); if (total == null) { 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 -> { 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(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(); 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); 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))); final InvestmentDepositBase saved = this.depositBaseRepository.saveAndFlush(base); return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) @@ -335,6 +349,7 @@ public class InvestmentFdService { } String nextDealNo(final LocalDate dealDate) { + InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_fd.deal_no"); final String prefix = InvestmentPurchaseWritePlatformServiceImpl.dealNoPrefix(dealDate) + "F"; 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, diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java index 9bbfb999b..e8d8ddc9a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestReportService.java @@ -68,8 +68,10 @@ public class InvestmentInterestReportService { public InvestmentInterestReportData report(final LocalDate from, final LocalDate to) { final List rows = new ArrayList<>(); 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()) - || InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) { + || InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus()) + || InvestmentApiConstants.SHARE.equals(p.getBondType())) { continue; } security(p, from, to, rows); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java index ad8180eaa..2a512ebec 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentMasterWritePlatformServiceImpl.java @@ -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.FAX; 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.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.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.LISTED; 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.SCRIPT_CODE_PREFIX; 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.SUBSCRIBED_CAPITAL; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.TDS_APPLICABLE; 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", "A " + party.getBondType() + " party named " + party.getPartyName() + " already exists", party.getPartyName()); } + requireShareInstitutionRules(party); if (!"FD".equals(party.getBondType())) { 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). */ private void applyParty(final InvestmentParty p, final JsonCommand command) { setIfPresent(command, BOND_TYPE, p::setBondType); @@ -224,6 +255,18 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID); 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)) { p.setTdsApplicable(command.booleanPrimitiveValueOfParameterNamed(TDS_APPLICABLE)); } @@ -260,6 +303,7 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste /** Next code in the PTY-001, PTY-002 … series. */ private String nextPartyCode() { + InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_party.party_code"); 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]+$'", 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. */ private String nextScriptCode(final String bondType) { + InvestmentPurchaseWritePlatformServiceImpl.lockNumbering(this.jdbcTemplate, "m_investment_script.script_code"); final String prefix = SCRIPT_CODE_PREFIX.get(bondType); final Integer max = this.jdbcTemplate.queryForObject( "SELECT MAX(CAST(SUBSTRING(script_code FROM 2) AS INTEGER)) FROM m_investment_script WHERE script_code ~ ?", diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentNonSlrService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentNonSlrService.java index 43c90b6b4..c6bfca69d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentNonSlrService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentNonSlrService.java @@ -115,7 +115,8 @@ public class InvestmentNonSlrService { BigDecimal unlistedOutstanding = BigDecimal.ZERO; BigDecimal nonSlrBase = BigDecimal.ZERO; 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; } final BigDecimal now = bookNow(p); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java index 47e6c882a..58a189e6d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java @@ -135,7 +135,8 @@ public class InvestmentPurchaseReadPlatformService { /** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */ public List scheduleRows(final InvestmentPurchase p) { // 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(); } final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index f69ba0225..c08ac4d16 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -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.exception.GeneralPlatformDomainRuleException; 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.InvestmentShareLimitData; import org.apache.fineract.portfolio.investment.domain.InvestmentParty; import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; @@ -94,6 +96,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur private final JdbcTemplate jdbcTemplate; private final InvestmentJournalPoster journalPoster; private final InvestmentNonSlrService nonSlrService; + private final InvestmentShareService shareService; @Override @Transactional @@ -127,6 +130,17 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur throw rule("party.fd", "Party " + party.getPartyCode() + " " + party.getPartyName() + " 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); if (nonSlr) { this.nonSlrService.requireEligible(script, command.stringValueOfParameterNamedAllowingNull(SUB_TYPE)); @@ -182,11 +196,20 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur computeMutualFund(p, command); } else if (nonSlr) { computeNonSlrBond(p, command, script); + } else if (share) { + computeShare(p, command, script); } else { computeSecurity(p, command, script); } 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()); if (!limit.isWithinLimits()) { 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); } + /** + * 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. */ private void computeMutualFund(final InvestmentPurchase p, final JsonCommand command) { 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. */ static String nextDealNo(final JdbcTemplate jdbcTemplate, final LocalDate dealDate) { + lockNumbering(jdbcTemplate, "m_investment_purchase.deal_no"); final String prefix = dealNoPrefix(dealDate); 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, @@ -380,7 +426,16 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur 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) { + lockNumbering(jdbcTemplate, "m_investment_purchase.cert_no"); final Integer max = jdbcTemplate.queryForObject( "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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleQuoter.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleQuoter.java index 0f86d1190..3aeb861aa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleQuoter.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleQuoter.java @@ -106,11 +106,13 @@ public class InvestmentSaleQuoter { } final BigDecimal available = availableUnits(p, ignoreSaleId); if (r.units().compareTo(available) > 0) { - throw rule("units.exceed", "Only " + available.stripTrailingZeros().toPlainString() + " units of deal " + p.getDealNo() - + " are available to sell"); + final boolean shares = InvestmentApiConstants.SHARE.equals(p.getBondType()); + 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 share = InvestmentApiConstants.SHARE.equals(p.getBondType()); // 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 BigDecimal unitValue = p.getScript().getFaceValuePerUnit(); @@ -121,6 +123,10 @@ public class InvestmentSaleQuoter { consideration = r.units().multiply(r.saleRate()).setScale(2, RoundingMode.HALF_UP); face = p.getUnits().signum() == 0 ? BigDecimal.ZERO : 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 { face = r.units().multiply(unitValue).setScale(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; BigDecimal accrued = BigDecimal.ZERO; BigDecimal receivable = BigDecimal.ZERO; - if (!nonSlr) { + if (!nonSlr && !share) { final LocalDate lastCoupon = lastCoupon(p, settlementDate); days = InvestmentInterestCalculator.days360(lastCoupon, settlementDate); 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) { requireActiveDebtHolding(p); 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 " - + "Sell / Redeem entry"); + throw rule("redeem.no.maturity", "Deal " + p.getDealNo() + " has no maturity date; redeem a mutual fund at its NAV, or a " + + "share refund, through the Sell / Redeem entry"); } if (p.getMaturityDate().isAfter(DateUtils.getBusinessLocalDate())) { 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())) { 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())) { - throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Bond and Mutual Fund holdings"); + if (!InvestmentApiConstants.DEBT_BOND_TYPES.contains(p.getBondType()) && !"MUTUAL_FUND".equals(p.getBondType()) + && !InvestmentApiConstants.SHARE.equals(p.getBondType())) { + throw rule("type.unsupported", "Sale and redemption are available for Govt Security, Bond, Mutual Fund and Share holdings"); } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java index ad2031b95..e6c4120d9 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java @@ -205,7 +205,9 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri // 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)) : 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 " + limit.stripTrailingZeros().toPlainString() + " (book value less premium still to be amortised); " + "sell or redeem the security to close it"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentShareService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentShareService.java new file mode 100644 index 000000000..b110362d2 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentShareService.java @@ -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 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>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DIVIDEND_PARAMS); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final List 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java index 2e98e314f..7c4295071 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java @@ -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_FUND; 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_RESERVE_GL_CODE; 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.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; 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.serialization.FromJsonHelper; 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.InvestmentSaleApiConstants; import org.apache.fineract.portfolio.investment.data.InvestmentValuationData; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate; 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.InvestmentPurchaseRepository; 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). * 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 - * still held. Gains and losses are netted within each class (Govt securities, corporate bonds, mutual funds); 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 + * still held. Shares have no market rate and are valued by rule (see {@link #shareLine}). Gains and losses are netted + * 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 * 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 BigDecimal HUNDRED = BigDecimal.valueOf(100); - private static final List VALUED_BOND_TYPES = List.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND"); + private static final List VALUED_BOND_TYPES = List.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND", "SHARE"); private final FromJsonHelper fromApiJsonHelper; private final InvestmentPurchaseRepository purchaseRepository; private final InvestmentSaleRepository saleRepository; private final InvestmentSecurityTransferRepository transferRepository; private final InvestmentDailyRateRepository dailyRateRepository; + private final InvestmentDividendRepository dividendRepository; private final InvestmentValuationRepository valuationRepository; private final InvestmentValuationLineRepository lineRepository; private final InvestmentJournalPoster journalPoster; @@ -156,6 +164,7 @@ public class InvestmentValuationService { v.setGovtRequired(required.get(CLASS_GOVT)); v.setBondRequired(required.get(CLASS_BOND)); v.setFundRequired(required.get(CLASS_FUND)); + v.setShareRequired(required.get(CLASS_SHARE)); v.setRequiredTotal(total); v.setPreviousTotal(previous); v.setDelta(delta); @@ -208,6 +217,7 @@ public class InvestmentValuationService { private Valued value(final LocalDate valuationDate, final String category) { final List lines = new ArrayList<>(); final List missing = new ArrayList<>(); + final Set rupeeTaken = new HashSet<>(); 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) || InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus()) @@ -230,6 +240,10 @@ public class InvestmentValuationService { if (units.signum() <= 0) { 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. final InvestmentScript script = this.transferRepository.findByPurchaseIdOrderByTransferDateAscIdAsc(p.getId()).stream() .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; } + /** + * 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 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. */ private static Map required(final List lines) { final Map 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) .reduce(BigDecimal.ZERO, BigDecimal::add); 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_BOND, v.getBondRequired()); 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()) .requiredTotal(v.getRequiredTotal()).previousTotal(v.getPreviousTotal()).delta(v.getDelta()) .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()) .bondType(p.getBondType()).scriptCode(l.getScript().getScriptCode()).scriptDesc(l.getScript().getScriptDesc()) .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) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 097253609..620c6b5a2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -203,4 +203,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0612_investment_share.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0612_investment_share.xml new file mode 100644 index 000000000..f9c5d8bed --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0612_investment_share.xml @@ -0,0 +1,109 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'DIVIDEND_INVESTMENTPURCHASE' + + + 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) + + + + + 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) + + +