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 c960f4dfc..e27504eea 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 @@ -183,6 +183,54 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder createInvestmentFd() { + this.actionName = "CREATE"; + this.entityName = "INVESTMENTFD"; + this.entityId = null; + this.href = "/investment/fds"; + return this; + } + + public CommandWrapperBuilder matureInvestmentFd(final Long fdId) { + this.actionName = "MATURE"; + this.entityName = "INVESTMENTFD"; + this.entityId = fdId; + this.href = "/investment/fds/" + fdId + "/mature"; + return this; + } + + public CommandWrapperBuilder prematureInvestmentFd(final Long fdId) { + this.actionName = "PREMATURE"; + this.entityName = "INVESTMENTFD"; + this.entityId = fdId; + this.href = "/investment/fds/" + fdId + "/premature"; + return this; + } + + public CommandWrapperBuilder renewInvestmentFd(final Long fdId) { + this.actionName = "RENEW"; + this.entityName = "INVESTMENTFD"; + this.entityId = fdId; + this.href = "/investment/fds/" + fdId + "/renew"; + return this; + } + + public CommandWrapperBuilder postInvestmentFdInterest(final Long fdId) { + this.actionName = "POSTINTEREST"; + this.entityName = "INVESTMENTFD"; + this.entityId = fdId; + this.href = "/investment/fds/" + fdId + "/interest"; + return this; + } + + public CommandWrapperBuilder setInvestmentDepositBase() { + this.actionName = "SETBASE"; + this.entityName = "INVESTMENTFD"; + this.entityId = null; + this.href = "/investment/fds/deposit-base"; + return this; + } + public CommandWrapperBuilder amortiseInvestmentPremium() { this.actionName = "AMORTISE"; this.entityName = "INVESTMENTSALE"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index bd63a0a72..ae1e5e703 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -46,6 +46,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; +import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService; import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; @@ -97,6 +98,7 @@ public class BranchDayRegisterApiResource { private final FixedDepositInterestPostingReportService fdInterestPostingReportService; private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; private final InvestmentPurchaseReadPlatformService investmentPurchaseReadPlatformService; + private final InvestmentFdInterestService investmentFdInterestService; private final Gson gson = new Gson(); private boolean isHeadOffice(final Long officeId) { @@ -273,6 +275,8 @@ public class BranchDayRegisterApiResource { // only the Head Office close shows it. result.put("investmentInterestPending", isHeadOffice(officeId) ? investmentPurchaseReadPlatformService.retrieveDueInterest(0) : List.of()); + // The same for FDs placed with other banks: interest vouchers and maturities due. A reminder only. + result.put("investmentFdPending", isHeadOffice(officeId) ? investmentFdInterestService.due(0) : List.of()); return gson.toJson(result); } 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 51b72428d..88392471d 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 @@ -52,6 +52,7 @@ public final class InvestmentApiConstants { // Common parameters public static final String BOND_TYPE = "bondType"; public static final String GL_ACCOUNT_ID = "glAccountId"; + public static final String INTEREST_GL_ACCOUNT_ID = "interestGlAccountId"; public static final String ADDRESS1 = "address1"; public static final String ADDRESS2 = "address2"; public static final String ADDRESS3 = "address3"; @@ -86,7 +87,7 @@ public final class InvestmentApiConstants { public static final String CORP_CODE = "corpCode"; public static final Set PARTY_PARAMS = Set.of(BOND_TYPE, PARTY_NAME, ADDRESS1, ADDRESS2, ADDRESS3, CITY, PINCODE, PHONE, FAX, - BANK_TYPE, GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE, + BANK_TYPE, GL_ACCOUNT_ID, INTEREST_GL_ACCOUNT_ID, RBI_CODE, EXPOSURE_LIMIT, LEI_NO, CP_BANK_NAME, CP_BRANCH_NAME, CP_ACCOUNT_NO, CP_IFSC, ACTIVE, LOCALE); public static final Set SCRIPT_PARAMS = Set.of(BOND_TYPE, SLR_TYPE, SCRIPT_DESC, GL_ACCOUNT_ID, ISIN, FACE_VALUE_PER_UNIT, 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 new file mode 100644 index 000000000..da5788aaf --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiConstants.java @@ -0,0 +1,119 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.api; + +import java.math.BigDecimal; +import java.util.Set; + +/** Fixed deposits placed with other banks. */ +public final class InvestmentFdApiConstants { + + private InvestmentFdApiConstants() {} + + public static final String ENTITY = "INVESTMENTFD"; + public static final String BOND_TYPE_FD = "FD"; + + public static final String STATUS_ACTIVE = "ACTIVE"; + public static final String STATUS_MATURED = "MATURED"; + public static final String STATUS_PREMATURE = "PREMATURE"; + public static final String STATUS_RENEWED = "RENEWED"; + + /** When the interest is paid: all at maturity (the usual case), or every half year / year. */ + public static final String PAYOUT_AT_MATURITY = "MATURITY"; + public static final String PAYOUT_HALF_YEARLY = "HALF_YEARLY"; + public static final String PAYOUT_YEARLY = "YEARLY"; + public static final Set PAYOUTS = Set.of(PAYOUT_AT_MATURITY, PAYOUT_HALF_YEARLY, PAYOUT_YEARLY); + + /** How the maturity amount is worked out, to compare with the bank's figure (Jitendra Sir). */ + public static final String CALC_SIMPLE = "SIMPLE"; + public static final String CALC_MONTHLY = "MONTHLY"; + public static final String CALC_QUARTERLY = "QUARTERLY"; + public static final String CALC_HALF_YEARLY = "HALF_YEARLY"; + public static final String CALC_YEARLY = "YEARLY"; + public static final Set CALC_TYPES = Set.of(CALC_SIMPLE, CALC_MONTHLY, CALC_QUARTERLY, CALC_HALF_YEARLY, CALC_YEARLY); + + /** RBI: deposits with other banks within 20% of total deposits as on the previous 31 March, and within 5% with one bank. */ + public static final BigDecimal GROSS_LIMIT_PERCENT = BigDecimal.valueOf(20); + public static final BigDecimal BANK_LIMIT_PERCENT = BigDecimal.valueOf(5); + /** Current accounts with banks also count towards the 20% inter-bank exposure. */ + public static final String BANK_BALANCE_HEADER_GL_CODE = "100200"; + /** Deposits of the bank, for the suggested total-deposits figure: everything under the deposit header, plus RD. */ + public static final String DEPOSIT_HEADER_GL_CODE = "200100"; + public static final String RD_DEPOSIT_GL_CODE = "200201"; + + public static final String PARTY_ID = "partyId"; + public static final String CERT_NO = "certNo"; + public static final String CERT_DATE = "certDate"; + public static final String DEAL_DATE = "dealDate"; + public static final String AMOUNT = "amount"; + public static final String INT_RATE = "intRate"; + public static final String MATURITY_DATE = "maturityDate"; + public static final String PAYOUT = "payout"; + public static final String CALC_TYPE = "calcType"; + public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId"; + 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 LOCALE = "locale"; + public static final String DATE_FORMAT = "dateFormat"; + + public static final String CLOSURE_MATURITY = "MATURITY"; + public static final String CLOSURE_PREMATURE = "PREMATURE"; + public static final String CLOSURE_RENEWAL = "RENEWAL"; + public static final String LINE_PROVISION = "PROVISION"; + public static final String LINE_PAYOUT = "PAYOUT"; + public static final String INTEREST_RECEIVABLE_GL_CODE = "100603"; + /** Tax the other bank deducts from the interest, recoverable from the tax department. */ + public static final String TDS_RECEIVABLE_GL_CODE = "100602"; + + public static final String CLOSE_DATE = "closeDate"; + public static final String RECEIVED_AMOUNT = "receivedAmount"; + public static final String TDS = "tds"; + public static final String PENAL_AMOUNT = "penalAmount"; + public static final String PRINCIPAL_BANK_GL_ACCOUNT_ID = "principalBankGlAccountId"; + public static final String INTEREST_BANK_GL_ACCOUNT_ID = "interestBankGlAccountId"; + public static final String NEW_CERT_NO = "newCertNo"; + public static final String NEW_CERT_DATE = "newCertDate"; + public static final String NEW_INT_RATE = "newIntRate"; + public static final String NEW_MATURITY_DATE = "newMaturityDate"; + public static final String NEW_PAYOUT = "newPayout"; + public static final String NEW_CALC_TYPE = "newCalcType"; + public static final String MATURITY_AMOUNT = "maturityAmount"; + public static final String RENEWAL_AMOUNT = "renewalAmount"; + public static final String DIFF_BANK_GL_ACCOUNT_ID = "diffBankGlAccountId"; + + public static final String LINE_MATURITY = "MATURITY"; + public static final String LINE_TYPE = "lineType"; + public static final String INT_DATE = "intDate"; + /** Reminders show lines due within this many days (as for Govt securities). */ + public static final int DUE_WITHIN_DAYS = 2; + public static final Set INTEREST_PARAMS = Set.of(LINE_TYPE, INT_DATE, RECEIVED_AMOUNT, TDS, BANK_GL_ACCOUNT_ID, LOCALE, + DATE_FORMAT); + + public static final Set MATURE_PARAMS = Set.of(RECEIVED_AMOUNT, TDS, PRINCIPAL_BANK_GL_ACCOUNT_ID, INTEREST_BANK_GL_ACCOUNT_ID, + REMARKS, LOCALE, DATE_FORMAT); + public static final Set PREMATURE_PARAMS = Set.of(CLOSE_DATE, RECEIVED_AMOUNT, TDS, PENAL_AMOUNT, PRINCIPAL_BANK_GL_ACCOUNT_ID, + INTEREST_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); + public static final Set RENEW_PARAMS = Set.of(NEW_CERT_NO, NEW_CERT_DATE, NEW_INT_RATE, NEW_MATURITY_DATE, NEW_PAYOUT, + NEW_CALC_TYPE, MATURITY_AMOUNT, RENEWAL_AMOUNT, TDS, DIFF_BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); + + 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); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiResource.java new file mode 100644 index 000000000..3179c7057 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentFdApiResource.java @@ -0,0 +1,186 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdDueData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData; +import org.apache.fineract.portfolio.investment.service.InvestmentFdClosureService; +import org.apache.fineract.portfolio.investment.service.InvestmentFdInterestService; +import org.apache.fineract.portfolio.investment.service.InvestmentFdService; +import org.springframework.stereotype.Component; + +@Path("/v1/investment/fds") +@Component +@Tag(name = "Investment FD", description = "Fixed deposits the bank places with other banks.") +@RequiredArgsConstructor +public class InvestmentFdApiResource { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final PlatformSecurityContext context; + private final InvestmentFdService fdService; + private final InvestmentFdClosureService closureService; + private final InvestmentFdInterestService interestService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "All FDs placed with other banks") + public List retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.fdService.retrieveAll(); + } + + @GET + @Path("quote") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Preview an FD: interest, maturity amount and the RBI limits", description = "Dates dd-MM-yyyy") + public InvestmentFdData quote(@QueryParam("partyId") final Long partyId, @QueryParam("amount") final BigDecimal amount, + @QueryParam("intRate") final BigDecimal intRate, @QueryParam("dealDate") final String dealDate, + @QueryParam("maturityDate") final String maturityDate, @QueryParam("payout") final String payout, + @QueryParam("calcType") final String calcType) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.fdService.quote(partyId, amount, intRate, date(dealDate), date(maturityDate), payout, calcType); + } + + @GET + @Path("due") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "FD interest vouchers and maturities due", description = "Due now or within withinDays (default 2)") + public List due(@QueryParam("withinDays") final Integer withinDays) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.interestService.due(withinDays == null ? InvestmentFdApiConstants.DUE_WITHIN_DAYS : withinDays); + } + + @GET + @Path("{fdId}/interest-schedule") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Interest lines of an FD", description = "31 Mar / 30 Sep provisions, payouts and maturity") + public InvestmentFdScheduleData schedule(@PathParam("fdId") final Long fdId) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.interestService.schedule(fdId); + } + + @POST + @Path("{fdId}/interest") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Post the next interest line of an FD", description = "lineType PROVISION | PAYOUT, intDate; payout: receivedAmount, tds") + public CommandProcessingResult postInterest(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return this.commandsSourceWritePlatformService + .logCommandSource(new CommandWrapperBuilder().postInvestmentFdInterest(fdId).withJson(apiRequestBodyAsJson).build()); + } + + @GET + @Path("{fdId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "One FD") + public InvestmentFdData retrieveOne(@PathParam("fdId") @Parameter(description = "fdId") final Long fdId) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.fdService.retrieveOne(fdId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Place an FD", description = "Dr the bank's FD GL / Cr our bank GL, dated the deal date; refused beyond the RBI limits.") + public CommandProcessingResult create(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().createInvestmentFd().withJson(apiRequestBodyAsJson).build(); + return this.commandsSourceWritePlatformService.logCommandSource(request); + } + + @GET + @Path("{fdId}/closure-quote") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Preview a maturity, premature closure or renewal", description = "type MATURITY | PREMATURE | RENEWAL") + public InvestmentFdClosureData closureQuote(@PathParam("fdId") final Long fdId, @QueryParam("type") final String type, + @QueryParam("closeDate") final String closeDate, @QueryParam("receivedAmount") final BigDecimal receivedAmount, + @QueryParam("tds") final BigDecimal tds, @QueryParam("renewalAmount") final BigDecimal renewalAmount) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentFdApiConstants.ENTITY); + return this.closureService.quote(fdId, type, date(closeDate), receivedAmount, tds, renewalAmount); + } + + @POST + @Path("{fdId}/mature") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Close an FD at maturity", description = "Dated the maturity date; amount as credited by the bank, optional TDS.") + public CommandProcessingResult mature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return this.commandsSourceWritePlatformService + .logCommandSource(new CommandWrapperBuilder().matureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build()); + } + + @POST + @Path("{fdId}/premature") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Close an FD before maturity", description = "Not calculated: the amount received (and any penal interest) is entered.") + public CommandProcessingResult premature(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return this.commandsSourceWritePlatformService + .logCommandSource(new CommandWrapperBuilder().prematureInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build()); + } + + @POST + @Path("{fdId}/renew") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Renew an FD at maturity", description = "New certificate, rate and maturity; money added or taken out goes through a bank GL.") + public CommandProcessingResult renew(@PathParam("fdId") final Long fdId, @Parameter(hidden = true) final String apiRequestBodyAsJson) { + return this.commandsSourceWritePlatformService + .logCommandSource(new CommandWrapperBuilder().renewInvestmentFd(fdId).withJson(apiRequestBodyAsJson).build()); + } + + @POST + @Path("deposit-base") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Record total deposits as on a 31 March", description = "The base of the RBI limits for the following year.") + public CommandProcessingResult setDepositBase(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().setInvestmentDepositBase().withJson(apiRequestBodyAsJson).build(); + return this.commandsSourceWritePlatformService.logCommandSource(request); + } + + private static LocalDate date(final String value) { + return StringUtils.isBlank(value) ? null : LocalDate.parse(value.trim(), DATE); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdClosureData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdClosureData.java new file mode 100644 index 000000000..2bc6f2658 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdClosureData.java @@ -0,0 +1,65 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.data; + +import java.math.BigDecimal; +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +/** What closing an FD (maturity, premature or renewal) works out to, and the voucher it posts. */ +@Getter +@Builder +public class InvestmentFdClosureData { + + private final Long fdId; + private final String dealNo; + private final String type; + private final String closeDate; + private final BigDecimal principal; + /** Interest the system works out for the period, to compare with the bank's figure. */ + private final BigDecimal expectedInterest; + /** Interest already booked by provisions and not yet received. */ + private final BigDecimal provisioned; + /** Money credited to our bank by the other bank (net of TDS). */ + private final BigDecimal receivedAmount; + private final BigDecimal tds; + private final BigDecimal penalAmount; + /** Gross interest as per the bank: received + TDS − principal. */ + private final BigDecimal interest; + /** Interest income of this entry: interest less what was provisioned (negative = reversed from income). */ + private final BigDecimal income; + /** Bank's interest less the system's. */ + private final BigDecimal difference; + private final BigDecimal renewalAmount; + /** Renewal: money added (positive) or taken out (negative) against the maturity amount. */ + private final BigDecimal topUp; + private final List lines; + + @Getter + @AllArgsConstructor + public static class Line { + + private final String side; + private final String glCode; + private final String glName; + private final BigDecimal amount; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdData.java new file mode 100644 index 000000000..0370324f5 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdData.java @@ -0,0 +1,103 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.data; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import lombok.Builder; +import lombok.Getter; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.portfolio.investment.domain.InvestmentFd; + +/** An FD placed with another bank; also the shape of a purchase preview (with the limit check). */ +@Getter +@Builder +public class InvestmentFdData { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final Long id; + private final String dealNo; + private final Long partyId; + private final String partyCode; + private final String partyName; + private final String certNo; + private final String certDate; + private final String dealDate; + private final BigDecimal principal; + private final BigDecimal intRate; + private final String maturityDate; + private final long days; + private final String payout; + private final String calcType; + private final BigDecimal expectedInterest; + private final BigDecimal expectedMaturityAmount; + /** Interest paid each half year / year for a payout FD. */ + private final BigDecimal periodInterest; + private final String fdGlCode; + private final String fdGlName; + private final String interestGlCode; + private final String interestGlName; + private final Long bankGlAccountId; + private final String bankGlCode; + private final String bankGlName; + private final String status; + private final String remarks; + private final String glTransactionId; + private final String voucherNo; + private final String renewedFromDealNo; + private final String closedOnDate; + private final String closureType; + private final BigDecimal closureAmount; + private final BigDecimal closureInterest; + private final BigDecimal closureTds; + private final BigDecimal closurePenal; + private final String closureVoucherNo; + private final String closureGlTransactionId; + private final InvestmentFdLimitData limit; + + public static InvestmentFdData from(final InvestmentFd f, final BigDecimal periodInterest) { + return builder().id(f.getId()).dealNo(f.getDealNo()).partyId(f.getParty().getId()).partyCode(f.getParty().getPartyCode()) + .partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).certDate(format(f.getCertDate())) + .dealDate(format(f.getDealDate())).principal(f.getPrincipal()).intRate(f.getIntRate()) + .maturityDate(format(f.getMaturityDate())).days(ChronoUnit.DAYS.between(f.getDealDate(), f.getMaturityDate())) + .payout(f.getPayout()).calcType(f.getCalcType()).expectedInterest(f.getExpectedInterest()) + .expectedMaturityAmount(f.getExpectedMaturityAmount()).periodInterest(periodInterest).fdGlCode(code(f.getFdGlAccount())) + .fdGlName(name(f.getFdGlAccount())).interestGlCode(code(f.getInterestGlAccount())).interestGlName(name(f.getInterestGlAccount())) + .bankGlAccountId(f.getBankGlAccount().getId()).bankGlCode(code(f.getBankGlAccount())).bankGlName(name(f.getBankGlAccount())) + .status(f.getStatus()).remarks(f.getRemarks()).glTransactionId(f.getGlTransactionId()).voucherNo(f.getVoucherNo()) + .renewedFromDealNo(f.getRenewedFrom() == null ? null : f.getRenewedFrom().getDealNo()).closedOnDate(format(f.getClosedOnDate())) + .closureType(f.getClosureType()).closureAmount(f.getClosureAmount()).closureInterest(f.getClosureInterest()) + .closureTds(f.getClosureTds()).closurePenal(f.getClosurePenal()).closureVoucherNo(f.getClosureVoucherNo()).closureGlTransactionId(f.getClosureGlTransactionId()).build(); + } + + private static String code(final GLAccount gl) { + return gl == null ? null : gl.getGlCode(); + } + + private static String name(final GLAccount gl) { + return gl == null ? null : gl.getName(); + } + + private static String format(final LocalDate date) { + return date == null ? null : date.format(DATE); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdDueData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdDueData.java new file mode 100644 index 000000000..171461d29 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdDueData.java @@ -0,0 +1,40 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.data; + +import java.math.BigDecimal; +import lombok.Builder; +import lombok.Getter; + +/** The next interest voucher (or the maturity) of an FD that is due, or due within the reminder window. */ +@Getter +@Builder +public class InvestmentFdDueData { + + private final Long fdId; + private final String dealNo; + private final String partyName; + private final String certNo; + /** PROVISION, PAYOUT or MATURITY. */ + private final String lineType; + private final String intDate; + private final BigDecimal amount; + private final boolean postable; + private final boolean overdue; +} 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 new file mode 100644 index 000000000..0ff1b0289 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdLimitData.java @@ -0,0 +1,46 @@ +/** + * 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 new FD stands against the RBI limits on deposits with other banks. */ +@Getter +@Builder +public class InvestmentFdLimitData { + + /** The 31 March the limits are based on. */ + private final String asOnDate; + /** Total deposits as on that date, as entered; empty until someone enters it. */ + private final BigDecimal totalDeposits; + /** The same figure worked out from the deposit GLs, as a suggestion. */ + private final BigDecimal glTotalDeposits; + private final BigDecimal grossLimit; + private final BigDecimal fdOutstanding; + private final BigDecimal bankBalances; + private final BigDecimal grossAfter; + private final BigDecimal bankLimit; + private final String partyName; + private final BigDecimal bankAfter; + 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/InvestmentFdScheduleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdScheduleData.java new file mode 100644 index 000000000..688406c6e --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentFdScheduleData.java @@ -0,0 +1,58 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.data; + +import java.math.BigDecimal; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +/** Interest lines of an FD: 31 Mar / 30 Sep provisions, payouts of a half-yearly / yearly FD, and maturity. */ +@Getter +@Builder +public class InvestmentFdScheduleData { + + private final InvestmentFdData fd; + /** Provisioned interest not yet received (interest receivable of this FD). */ + private final BigDecimal provisioned; + private final List rows; + + @Getter + @Builder + public static class Row { + + /** PROVISION, PAYOUT or MATURITY (shown for information; closed on the Maturity screen). */ + private final String lineType; + private final String intDate; + private final String fromDate; + private final String toDate; + private final long days; + private final BigDecimal grossInterest; + /** Payout: the part already provisioned, cleared from interest receivable. */ + private final BigDecimal alreadyBooked; + private final BigDecimal tds; + /** Provision: interest booked. Payout: interest income of the payout (gross less already booked). */ + private final BigDecimal amount; + private final boolean posted; + private final String voucherNo; + private final String glTransactionId; + /** The next line to post, and its date has come. */ + private final boolean postable; + } +} 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 d74792478..f4965a878 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 @@ -42,6 +42,9 @@ public class InvestmentPartyData { private final Long glAccountId; private final String glCode; private final String glName; + private final Long interestGlAccountId; + private final String interestGlCode; + private final String interestGlName; private final String rbiCode; private final BigDecimal limit; private final String leiNo; @@ -56,6 +59,9 @@ public class InvestmentPartyData { .partyName(p.getPartyName()).address1(p.getAddress1()).address2(p.getAddress2()).address3(p.getAddress3()) .city(p.getCity()).pincode(p.getPincode()).phone(p.getPhone()).fax(p.getFax()).bankType(p.getBankType()) .glAccountId(p.getGlAccount().getId()).glCode(p.getGlAccount().getGlCode()).glName(p.getGlAccount().getName()) + .interestGlAccountId(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getId()) + .interestGlCode(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getGlCode()) + .interestGlName(p.getInterestGlAccount() == null ? null : p.getInterestGlAccount().getName()) .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(); } 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 new file mode 100644 index 000000000..7ccc685e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBase.java @@ -0,0 +1,47 @@ +/** + * 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.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** The bank's total deposits as on a 31 March: the base for the RBI limits on deposits placed with other banks. */ +@Entity +@Table(name = "m_investment_deposit_base") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentDepositBase extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "as_on_date", nullable = false, unique = true) + private LocalDate asOnDate; + + @Column(name = "total_deposits", scale = 6, precision = 19, nullable = false) + private BigDecimal totalDeposits; + + @Column(name = "remarks", length = 500) + private String remarks; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBaseRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBaseRepository.java new file mode 100644 index 000000000..812605ee4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentDepositBaseRepository.java @@ -0,0 +1,28 @@ +/** + * 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.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InvestmentDepositBaseRepository extends JpaRepository { + + Optional findByAsOnDate(LocalDate asOnDate); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFd.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFd.java new file mode 100644 index 000000000..118c8f9dd --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFd.java @@ -0,0 +1,135 @@ +/** + * 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 fixed deposit the bank has placed with another bank, from placement to maturity, premature closure or renewal. */ +@Entity +@Table(name = "m_investment_fd") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentFd extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "deal_no", nullable = false, length = 20, unique = true) + private String dealNo; + + @ManyToOne + @JoinColumn(name = "party_id", nullable = false) + private InvestmentParty party; + + /** The bank's FD GL (from the party), debited when the money is placed. */ + @ManyToOne + @JoinColumn(name = "fd_gl_account_id", nullable = false) + private GLAccount fdGlAccount; + + @ManyToOne + @JoinColumn(name = "interest_gl_account_id", nullable = false) + private GLAccount interestGlAccount; + + /** Certificate (receipt) number issued by the other bank. */ + @Column(name = "cert_no", nullable = false, length = 50) + private String certNo; + + @Column(name = "cert_date") + private LocalDate certDate; + + @Column(name = "deal_date", nullable = false) + private LocalDate dealDate; + + @Column(name = "principal", scale = 6, precision = 19, nullable = false) + private BigDecimal principal; + + @Column(name = "int_rate", scale = 6, precision = 19, nullable = false) + private BigDecimal intRate; + + @Column(name = "maturity_date", nullable = false) + private LocalDate maturityDate; + + @Column(name = "payout", nullable = false, length = 20) + private String payout; + + @Column(name = "calc_type", nullable = false, length = 20) + private String calcType; + + @Column(name = "expected_interest", scale = 6, precision = 19) + private BigDecimal expectedInterest; + + @Column(name = "expected_maturity_amount", scale = 6, precision = 19) + private BigDecimal expectedMaturityAmount; + + /** Our bank account the money was paid from. */ + @ManyToOne + @JoinColumn(name = "bank_gl_account_id", nullable = false) + private GLAccount bankGlAccount; + + /** The FD this one renews, if any. */ + @ManyToOne + @JoinColumn(name = "renewed_from_id") + private InvestmentFd renewedFrom; + + @Column(name = "status", nullable = false, length = 20) + private String status; + + @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; + + @Column(name = "closed_on_date") + private LocalDate closedOnDate; + + /** MATURITY, PREMATURE or RENEWAL. */ + @Column(name = "closure_type", length = 20) + private String closureType; + + @Column(name = "closure_amount", scale = 6, precision = 19) + private BigDecimal closureAmount; + + @Column(name = "closure_interest", scale = 6, precision = 19) + private BigDecimal closureInterest; + + @Column(name = "closure_tds", scale = 6, precision = 19) + private BigDecimal closureTds; + + @Column(name = "closure_penal", scale = 6, precision = 19) + private BigDecimal closurePenal; + + @Column(name = "closure_gl_transaction_id", length = 50) + private String closureGlTransactionId; + + @Column(name = "closure_voucher_no", length = 30) + private String closureVoucherNo; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterest.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterest.java new file mode 100644 index 000000000..605c1426b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterest.java @@ -0,0 +1,87 @@ +/** + * 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 posted interest line of an FD: a 31 Mar / 30 Sep provision, or a half-yearly / yearly payout. */ +@Entity +@Table(name = "m_investment_fd_interest") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentFdInterest extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne + @JoinColumn(name = "fd_id", nullable = false) + private InvestmentFd fd; + + /** PROVISION or PAYOUT. */ + @Column(name = "line_type", nullable = false, length = 20) + private String lineType; + + @Column(name = "int_date", nullable = false) + private LocalDate intDate; + + @Column(name = "from_date") + private LocalDate fromDate; + + @Column(name = "to_date") + private LocalDate toDate; + + @Column(name = "days") + private Integer days; + + @Column(name = "gross_interest", scale = 6, precision = 19) + private BigDecimal grossInterest; + + /** For a payout: the part already provisioned, cleared from interest receivable. */ + @Column(name = "already_booked", scale = 6, precision = 19) + private BigDecimal alreadyBooked; + + @Column(name = "tds", scale = 6, precision = 19) + private BigDecimal tds; + + @Column(name = "amount", scale = 6, precision = 19, nullable = false) + private BigDecimal amount; + + @ManyToOne + @JoinColumn(name = "bank_gl_account_id") + private GLAccount bankGlAccount; + + @Column(name = "gl_transaction_id", length = 50) + private String glTransactionId; + + @Column(name = "voucher_no", length = 30) + private String voucherNo; + + @Column(name = "posted_on_date") + private LocalDate postedOnDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterestRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterestRepository.java new file mode 100644 index 000000000..0d077c0b9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdInterestRepository.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InvestmentFdInterestRepository extends JpaRepository { + + List findByFdIdOrderByIntDateAscIdAsc(Long fdId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdRepository.java new file mode 100644 index 000000000..0a65bd88b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentFdRepository.java @@ -0,0 +1,38 @@ +/** + * 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.LockModeType; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface InvestmentFdRepository extends JpaRepository { + + @Lock(value = LockModeType.PESSIMISTIC_WRITE) + @Query("select f from InvestmentFd f where f.id = :fdId") + Optional findByIdWithLock(@Param("fdId") Long fdId); + + List findByStatusOrderByIdAsc(String status); + + boolean existsByPartyIdAndCertNoIgnoreCase(Long partyId, String certNo); +} 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 9187bf261..2402e964a 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 @@ -78,6 +78,11 @@ public class InvestmentParty extends AbstractAuditableWithUTCDateTimeCustom {} +public interface InvestmentPartyRepository extends JpaRepository { + + /** Locks the bank so two FDs with it are checked against the 5% limit one after the other. */ + @org.springframework.data.jpa.repository.Lock(value = jakarta.persistence.LockModeType.PESSIMISTIC_WRITE) + @org.springframework.data.jpa.repository.Query("select p from InvestmentParty p where p.id = :partyId") + java.util.Optional findByIdWithLock(@org.springframework.data.repository.query.Param("partyId") Long partyId);} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentFdCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentFdCommandHandler.java new file mode 100644 index 000000000..6e1d8a267 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentFdCommandHandler.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.InvestmentFdService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "CREATE") +@RequiredArgsConstructor +public class CreateInvestmentFdCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdService fdService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.fdService.create(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/MatureInvestmentFdCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/MatureInvestmentFdCommandHandler.java new file mode 100644 index 000000000..458c8b521 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/MatureInvestmentFdCommandHandler.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.InvestmentFdClosureService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "MATURE") +@RequiredArgsConstructor +public class MatureInvestmentFdCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdClosureService closureService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.closureService.mature(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentFdInterestCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentFdInterestCommandHandler.java new file mode 100644 index 000000000..02f8a51a4 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentFdInterestCommandHandler.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.InvestmentFdInterestService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "POSTINTEREST") +@RequiredArgsConstructor +public class PostInvestmentFdInterestCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdInterestService interestService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.interestService.post(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PrematureInvestmentFdCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PrematureInvestmentFdCommandHandler.java new file mode 100644 index 000000000..69c8eff22 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PrematureInvestmentFdCommandHandler.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.InvestmentFdClosureService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "PREMATURE") +@RequiredArgsConstructor +public class PrematureInvestmentFdCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdClosureService closureService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.closureService.premature(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/RenewInvestmentFdCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/RenewInvestmentFdCommandHandler.java new file mode 100644 index 000000000..d10b5bb26 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/RenewInvestmentFdCommandHandler.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.InvestmentFdClosureService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "RENEW") +@RequiredArgsConstructor +public class RenewInvestmentFdCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdClosureService closureService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.closureService.renew(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SetInvestmentDepositBaseCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SetInvestmentDepositBaseCommandHandler.java new file mode 100644 index 000000000..8d4f611c3 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SetInvestmentDepositBaseCommandHandler.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.InvestmentFdService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTFD", action = "SETBASE") +@RequiredArgsConstructor +public class SetInvestmentDepositBaseCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFdService fdService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.fdService.setDepositBase(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 0310984d0..b51d62a1d 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,6 +36,7 @@ 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.INTEREST_GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; @@ -99,6 +100,10 @@ public final class InvestmentMasterCommandFromApiJsonDeserializer { validateBondType(element, v, create); validateRequiredString(element, v, PARTY_NAME, 150, create); validateGlAccountId(element, v, create); + if (this.fromApiJsonHelper.parameterExists(INTEREST_GL_ACCOUNT_ID, element)) { + v.reset().parameter(INTEREST_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(INTEREST_GL_ACCOUNT_ID, element)) + .ignoreIfNull().longGreaterThanZero(); + } validateAddress(element, v); validateOptionalString(element, v, BANK_TYPE, 60); validateOptionalString(element, v, RBI_CODE, 30); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdCalculator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdCalculator.java new file mode 100644 index 000000000..0356a13eb --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdCalculator.java @@ -0,0 +1,79 @@ +/** + * 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.InvestmentFdApiConstants.CALC_HALF_YEARLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_MONTHLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_QUARTERLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_SIMPLE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_YEARLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT_AT_MATURITY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT_HALF_YEARLY; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; + +/** + * Interest on an FD with another bank, on actual days over a 365-day year. A payout FD (interest paid every half + * year / year) earns simple interest; an FD paid at maturity compounds at the chosen frequency, or is simple. The + * user also enters the bank's own figure at maturity, so a different compounding shows up as a difference. + */ +public final class InvestmentFdCalculator { + + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final BigDecimal DAYS_IN_YEAR = BigDecimal.valueOf(365); + + private InvestmentFdCalculator() {} + + public static BigDecimal interest(final BigDecimal principal, final BigDecimal ratePercent, final LocalDate from, final LocalDate to, + final String calcType, final String payout) { + final long days = ChronoUnit.DAYS.between(from, to); + if (days <= 0 || principal == null || ratePercent == null || principal.signum() <= 0 || ratePercent.signum() <= 0) { + return BigDecimal.ZERO; + } + if (!PAYOUT_AT_MATURITY.equals(payout) || CALC_SIMPLE.equals(calcType)) { + return principal.multiply(ratePercent).multiply(BigDecimal.valueOf(days)).divide(HUNDRED.multiply(DAYS_IN_YEAR), 2, + RoundingMode.HALF_UP); + } + final int n = periodsPerYear(calcType); + final double factor = Math.pow(1 + ratePercent.doubleValue() / 100 / n, n * (days / 365.0)); + return principal.multiply(BigDecimal.valueOf(factor - 1)).setScale(2, RoundingMode.HALF_UP); + } + + /** Interest paid each half year or year on a payout FD; zero for an FD paid at maturity. */ + public static BigDecimal periodInterest(final BigDecimal principal, final BigDecimal ratePercent, final String payout) { + if (PAYOUT_AT_MATURITY.equals(payout) || principal == null || ratePercent == null) { + return BigDecimal.ZERO; + } + final BigDecimal perYear = principal.multiply(ratePercent).divide(HUNDRED, 2, RoundingMode.HALF_UP); + return PAYOUT_HALF_YEARLY.equals(payout) ? perYear.divide(BigDecimal.valueOf(2), 2, RoundingMode.HALF_UP) : perYear; + } + + private static int periodsPerYear(final String calcType) { + return switch (calcType) { + case CALC_MONTHLY -> 12; + case CALC_QUARTERLY -> 4; + case CALC_HALF_YEARLY -> 2; + case CALC_YEARLY -> 1; + default -> throw new IllegalArgumentException("Unknown calculation type " + calcType); + }; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdClosureService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdClosureService.java new file mode 100644 index 000000000..598b48cf9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdClosureService.java @@ -0,0 +1,463 @@ +/** + * 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.InvestmentFdApiConstants.CALC_TYPES; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CLOSE_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CLOSURE_MATURITY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CLOSURE_PREMATURE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CLOSURE_RENEWAL; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.DIFF_BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INTEREST_BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INTEREST_RECEIVABLE_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_PAYOUT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_PROVISION; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.MATURE_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.MATURITY_AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_CALC_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_CERT_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_CERT_NO; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_INT_RATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_MATURITY_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.NEW_PAYOUT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUTS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PENAL_AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PREMATURE_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PRINCIPAL_BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RECEIVED_AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.REMARKS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RENEWAL_AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RENEW_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_ACTIVE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_MATURED; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_PREMATURE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_RENEWED; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TDS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TDS_RECEIVABLE_GL_CODE; + +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 java.util.Set; +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.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.data.InvestmentFdClosureData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdLimitData; +import org.apache.fineract.portfolio.investment.domain.InvestmentFd; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterest; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Closing an FD placed with another bank (Jitendra Sir). At maturity the system works out the interest and the user + * enters what the bank actually paid; the two are shown side by side, so a different compounding shows up. A premature + * closure is not calculated: the user enters the amount received (and any penal interest). The interest is received + * plus TDS less the principal; what was already provisioned clears interest receivable and the rest is income (a + * shortfall is reversed from income). Renewal closes the FD and opens a new certificate on the maturity date: renewed as + * it is, no money moves; money added or taken out goes through the bank GL the user names. TDS, when the bank deducts + * it, goes to TDS receivable. + */ +@Service +@RequiredArgsConstructor +public class InvestmentFdClosureService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final FromJsonHelper fromApiJsonHelper; + private final InvestmentFdRepository fdRepository; + private final InvestmentFdInterestRepository interestRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final InvestmentJournalPoster journalPoster; + private final InvestmentFdService fdService; + private final InvestmentFdInterestService interestService; + + /** Preview of a closure; nothing is posted. */ + @Transactional(readOnly = true) + public InvestmentFdClosureData quote(final Long fdId, final String type, final LocalDate closeDate, final BigDecimal receivedAmount, + final BigDecimal tds, final BigDecimal renewalAmount) { + final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist")); + final String t = CLOSURE_PREMATURE.equals(type) || CLOSURE_RENEWAL.equals(type) ? type : CLOSURE_MATURITY; + final LocalDate date = CLOSURE_PREMATURE.equals(t) ? (closeDate == null ? DateUtils.getBusinessLocalDate() : closeDate) : f.getMaturityDate(); + final Figures x = figures(f, t, date, receivedAmount, tds, renewalAmount); + final Entry e = CLOSURE_RENEWAL.equals(t) ? renewalEntry(f, x, f.getBankGlAccount()) : closureEntry(f, x, f.getBankGlAccount(), f.getBankGlAccount()); + return x.toData(f, t, date, null, e); + } + + @Transactional + public CommandProcessingResult mature(final Long fdId, final JsonCommand command) { + validate(command.json(), MATURE_PARAMS, (v, el) -> { + amountOptional(v, el, RECEIVED_AMOUNT); + amountOptional(v, el, TDS); + }); + final InvestmentFd f = active(fdId); + final LocalDate date = f.getMaturityDate(); + if (date.isAfter(DateUtils.getBusinessLocalDate())) { + throw InvestmentFdService.rule("mature.before.date", "FD " + f.getDealNo() + " matures on " + date.format(DATE) + + "; close it before then as a premature closure"); + } + return close(f, command, CLOSURE_MATURITY, date, STATUS_MATURED); + } + + @Transactional + public CommandProcessingResult premature(final Long fdId, final JsonCommand command) { + validate(command.json(), PREMATURE_PARAMS, (v, el) -> { + v.reset().parameter(CLOSE_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(CLOSE_DATE, el)).notNull(); + v.reset().parameter(RECEIVED_AMOUNT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(RECEIVED_AMOUNT, el)).notNull() + .positiveAmount(); + amountOptional(v, el, TDS); + amountOptional(v, el, PENAL_AMOUNT); + }); + final InvestmentFd f = active(fdId); + final LocalDate date = command.localDateValueOfParameterNamed(CLOSE_DATE); + if (date.isAfter(DateUtils.getBusinessLocalDate())) { + throw InvestmentFdService.rule("close.future", "The closure date cannot be after today"); + } + if (!date.isAfter(f.getDealDate()) || !date.isBefore(f.getMaturityDate())) { + throw InvestmentFdService.rule("premature.date", "A premature closure falls after the deal date " + f.getDealDate().format(DATE) + + " and before maturity " + f.getMaturityDate().format(DATE)); + } + return close(f, command, CLOSURE_PREMATURE, date, STATUS_PREMATURE); + } + + @Transactional + public CommandProcessingResult renew(final Long fdId, final JsonCommand command) { + validate(command.json(), RENEW_PARAMS, (v, el) -> { + v.reset().parameter(NEW_CERT_NO).value(this.fromApiJsonHelper.extractStringNamed(NEW_CERT_NO, el)).notBlank().notExceedingLengthOf(50); + v.reset().parameter(NEW_INT_RATE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(NEW_INT_RATE, el)).notNull() + .positiveAmount(); + v.reset().parameter(NEW_MATURITY_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(NEW_MATURITY_DATE, el)).notNull(); + v.reset().parameter(NEW_PAYOUT).value(this.fromApiJsonHelper.extractStringNamed(NEW_PAYOUT, el)).ignoreIfNull() + .isOneOfTheseValues(PAYOUTS.toArray()); + v.reset().parameter(NEW_CALC_TYPE).value(this.fromApiJsonHelper.extractStringNamed(NEW_CALC_TYPE, el)).ignoreIfNull() + .isOneOfTheseValues(CALC_TYPES.toArray()); + amountOptional(v, el, MATURITY_AMOUNT); + amountOptional(v, el, RENEWAL_AMOUNT); + amountOptional(v, el, TDS); + }); + final InvestmentFd f = active(fdId); + final LocalDate date = f.getMaturityDate(); + if (date.isAfter(DateUtils.getBusinessLocalDate())) { + throw InvestmentFdService.rule("renew.before.date", "FD " + f.getDealNo() + " can be renewed on or after its maturity " + date.format(DATE)); + } + final LocalDate newMaturity = command.localDateValueOfParameterNamed(NEW_MATURITY_DATE); + if (!newMaturity.isAfter(date)) { + throw InvestmentFdService.rule("renew.maturity", "The new maturity date must be after " + date.format(DATE)); + } + final String newCertNo = command.stringValueOfParameterNamed(NEW_CERT_NO).trim(); + if (this.fdRepository.existsByPartyIdAndCertNoIgnoreCase(f.getParty().getId(), newCertNo)) { + throw InvestmentFdService.rule("cert.duplicate", "Certificate " + newCertNo + " of " + f.getParty().getPartyName() + " is already entered"); + } + final Figures x = figures(f, CLOSURE_RENEWAL, date, amount(command, MATURITY_AMOUNT), amount(command, TDS), amount(command, RENEWAL_AMOUNT)); + if (x.renewalAmount.signum() <= 0) { + throw InvestmentFdService.rule("renew.amount", "The renewal amount must be more than zero"); + } + GLAccount diffGl = null; + if (x.topUp.signum() != 0) { + final Long diffGlId = command.longValueOfParameterNamed(DIFF_BANK_GL_ACCOUNT_ID); + if (diffGlId == null) { + throw InvestmentFdService.rule("renew.bank.gl", x.topUp.signum() > 0 + ? "Choose the bank account the extra " + plain(x.topUp) + " is paid from" + : "Choose the bank account the " + plain(x.topUp.negate()) + " taken out is credited to"); + } + diffGl = bankGl(diffGlId, f); + } + final BigDecimal added = x.renewalAmount.subtract(f.getPrincipal()); + if (added.signum() > 0) { + final InvestmentFdLimitData limit = this.fdService.limits(date, f.getParty(), added); + if (!limit.isWithinLimits()) { + throw InvestmentFdService.rule("limit.exceeded", limit.getMessage()); + } + } + this.interestService.requirePostedBefore(f, date); + final Entry e = renewalEntry(f, x, diffGl); + final CommandProcessingResult posted = e.isEmpty() ? null + : this.journalPoster.post(date, f.getDealNo(), "FD renewed: deal " + f.getDealNo() + ", certificate " + f.getCertNo() + " → " + + newCertNo + " with " + f.getParty().getPartyName() + " (interest " + plain(x.interest) + tdsNote(x) + ")", e.lines); + + final InvestmentFd n = new InvestmentFd(); + n.setDealNo(this.fdService.nextDealNo(date)); + n.setParty(f.getParty()); + n.setFdGlAccount(f.getParty().getGlAccount()); + n.setInterestGlAccount(f.getParty().getInterestGlAccount() != null ? f.getParty().getInterestGlAccount() : f.getInterestGlAccount()); + n.setCertNo(newCertNo); + n.setCertDate(command.localDateValueOfParameterNamed(NEW_CERT_DATE)); + n.setDealDate(date); + n.setPrincipal(x.renewalAmount); + n.setIntRate(command.bigDecimalValueOfParameterNamed(NEW_INT_RATE)); + n.setMaturityDate(newMaturity); + n.setPayout(StringUtils.defaultIfBlank(command.stringValueOfParameterNamed(NEW_PAYOUT), f.getPayout())); + n.setCalcType(StringUtils.defaultIfBlank(command.stringValueOfParameterNamed(NEW_CALC_TYPE), f.getCalcType())); + final BigDecimal newInterest = InvestmentFdCalculator.interest(n.getPrincipal(), n.getIntRate(), date, newMaturity, n.getCalcType(), + n.getPayout()); + n.setExpectedInterest(newInterest); + n.setExpectedMaturityAmount(InvestmentFdService.maturityAmount(n.getPrincipal(), newInterest, n.getPayout())); + n.setBankGlAccount(diffGl != null ? diffGl : f.getBankGlAccount()); + n.setRenewedFrom(f); + n.setStatus(STATUS_ACTIVE); + n.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS))); + n.setGlTransactionId(posted == null ? null : posted.getTransactionId()); + n.setVoucherNo(posted == null ? null : voucherNo(posted)); + + closeRecord(f, x, CLOSURE_RENEWAL, date, STATUS_RENEWED, posted); + this.fdRepository.saveAndFlush(f); + final InvestmentFd saved = this.fdRepository.saveAndFlush(n); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).withSubEntityId(f.getId()) + .withResourceIdAsString(saved.getVoucherNo()).with(Map.of("dealNo", saved.getDealNo(), "voucherNo", + String.valueOf(saved.getVoucherNo()), "interest", x.interest)) + .build(); + } + + // -------------------------------------------------------------------------------------------------- shared + + private CommandProcessingResult close(final InvestmentFd f, final JsonCommand command, final String type, final LocalDate date, + final String status) { + this.interestService.requirePostedBefore(f, date); + final Figures x = figures(f, type, date, amount(command, RECEIVED_AMOUNT), amount(command, TDS), + null).withPenal(amount(command, PENAL_AMOUNT)); + if (x.received.compareTo(f.getPrincipal()) < 0) { + throw InvestmentFdService.rule("received.below.principal", "The amount received " + plain(x.received) + + " is less than the principal " + plain(f.getPrincipal()) + "; check the figure"); + } + final GLAccount principalGl = command.longValueOfParameterNamed(PRINCIPAL_BANK_GL_ACCOUNT_ID) == null ? f.getBankGlAccount() + : bankGl(command.longValueOfParameterNamed(PRINCIPAL_BANK_GL_ACCOUNT_ID), f); + final GLAccount interestGl = command.longValueOfParameterNamed(INTEREST_BANK_GL_ACCOUNT_ID) == null ? principalGl + : bankGl(command.longValueOfParameterNamed(INTEREST_BANK_GL_ACCOUNT_ID), f); + final Entry e = closureEntry(f, x, principalGl, interestGl); + final String what = CLOSURE_PREMATURE.equals(type) ? "FD closed premature" : "FD matured"; + final CommandProcessingResult posted = this.journalPoster.post(date, f.getDealNo(), what + ": deal " + f.getDealNo() + ", certificate " + + f.getCertNo() + " with " + f.getParty().getPartyName() + " (interest " + plain(x.interest) + tdsNote(x) + ")", e.lines); + f.setRemarks(StringUtils.defaultIfBlank(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS)), f.getRemarks())); + closeRecord(f, x, type, date, status, posted); + this.fdRepository.saveAndFlush(f); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(f.getId()) + .withResourceIdAsString(f.getClosureVoucherNo()).withTransactionId(posted.getTransactionId()) + .with(Map.of("voucherNo", String.valueOf(f.getClosureVoucherNo()), "interest", x.interest)).build(); + } + + /** Dr bank (principal, and interest net of TDS), Dr TDS receivable / Cr FD GL, Cr interest receivable, ± interest income. */ + private Entry closureEntry(final InvestmentFd f, final Figures x, final GLAccount principalGl, final GLAccount interestGl) { + final Entry e = new Entry(); + e.dr(principalGl, f.getPrincipal()); + e.dr(interestGl, x.received.subtract(f.getPrincipal())); + e.dr(gl(TDS_RECEIVABLE_GL_CODE), x.tds); + e.cr(f.getFdGlAccount(), f.getPrincipal()); + income(e, f, x); + return e; + } + + /** Interest income and receivable, plus the FD GL moving from the old principal to the renewal amount and the bank for any difference. */ + private Entry renewalEntry(final InvestmentFd f, final Figures x, final GLAccount diffGl) { + final Entry e = new Entry(); + e.dr(gl(TDS_RECEIVABLE_GL_CODE), x.tds); + income(e, f, x); + final BigDecimal fdChange = x.renewalAmount.subtract(f.getPrincipal()); + if (fdChange.signum() > 0) { + e.dr(f.getFdGlAccount(), fdChange); + } else { + e.cr(f.getFdGlAccount(), fdChange.negate()); + } + if (diffGl != null) { + if (x.topUp.signum() > 0) { + e.cr(diffGl, x.topUp); + } else { + e.dr(diffGl, x.topUp.negate()); + } + } + return e; + } + + private void income(final Entry e, final InvestmentFd f, final Figures x) { + e.cr(gl(INTEREST_RECEIVABLE_GL_CODE), x.provisioned); + if (x.income.signum() > 0) { + e.cr(f.getInterestGlAccount(), x.income); + } else { + // Less interest than already provisioned: the shortfall comes back out of income. + e.dr(f.getInterestGlAccount(), x.income.negate()); + } + } + + private Figures figures(final InvestmentFd f, final String type, final LocalDate date, final BigDecimal amountIn, final BigDecimal tdsIn, + final BigDecimal renewalIn) { + final List lines = this.interestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId()); + BigDecimal provisioned = BigDecimal.ZERO; + LocalDate lastPaid = f.getDealDate(); + for (final InvestmentFdInterest l : lines) { + if (LINE_PROVISION.equals(l.getLineType())) { + provisioned = provisioned.add(l.getAmount()); + } else if (LINE_PAYOUT.equals(l.getLineType())) { + provisioned = provisioned.subtract(zero(l.getAlreadyBooked())); + lastPaid = l.getIntDate(); + } + } + final BigDecimal expected = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, date, f.getCalcType(), + f.getPayout()); + final BigDecimal tds = scale(zero(tdsIn)); + // Maturity / renewal default to the system's figure; a premature closure always needs the bank's amount. + final BigDecimal received = amountIn != null ? scale(amountIn) : f.getPrincipal().add(expected).subtract(tds); + final BigDecimal interest = received.add(tds).subtract(f.getPrincipal()); + final BigDecimal renewal = renewalIn != null ? scale(renewalIn) : received; + return new Figures(expected, provisioned.max(BigDecimal.ZERO), received, tds, null, interest, + interest.subtract(provisioned.max(BigDecimal.ZERO)), renewal, renewal.subtract(received)); + } + + private void closeRecord(final InvestmentFd f, final Figures x, final String type, final LocalDate date, final String status, + final CommandProcessingResult posted) { + f.setStatus(status); + f.setClosureType(type); + f.setClosedOnDate(date); + f.setClosureAmount(x.received); + f.setClosureInterest(x.interest); + f.setClosureTds(x.tds); + f.setClosurePenal(x.penal); + f.setClosureGlTransactionId(posted == null ? null : posted.getTransactionId()); + f.setClosureVoucherNo(posted == null ? null : voucherNo(posted)); + } + + private InvestmentFd active(final Long fdId) { + final InvestmentFd f = this.fdRepository.findByIdWithLock(fdId) + .orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist")); + if (!STATUS_ACTIVE.equals(f.getStatus())) { + throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase()); + } + return f; + } + + private GLAccount bankGl(final Long glId, final InvestmentFd f) { + final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glId); + if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) { + throw InvestmentFdService.rule("bank.gl.invalid", "GL " + gl.getGlCode() + " must be an enabled detail Asset account (our bank account)"); + } + if (gl.getId().equals(f.getFdGlAccount().getId())) { + throw InvestmentFdService.rule("bank.gl.same", "Choose our bank account, not the FD GL itself"); + } + return gl; + } + + private GLAccount gl(final String code) { + return this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(code); + } + + private BigDecimal amount(final JsonCommand command, final String param) { + return command.parameterExists(param) ? command.bigDecimalValueOfParameterNamed(param) : null; + } + + private void amountOptional(final DataValidatorBuilder v, final JsonElement el, final String param) { + v.reset().parameter(param).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, el)).ignoreIfNull() + .zeroOrPositiveAmount(); + } + + private void validate(final String json, final Set supported, + final java.util.function.BiConsumer rules) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supported); + final List errors = new ArrayList<>(); + rules.accept(new DataValidatorBuilder(errors).resource("investmentfd"), this.fromApiJsonHelper.parse(json)); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static String tdsNote(final Figures x) { + return x.tds.signum() > 0 ? ", TDS " + plain(x.tds) : ""; + } + + private static BigDecimal zero(final BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private static BigDecimal scale(final BigDecimal v) { + return v.setScale(2, RoundingMode.HALF_UP); + } + + private static String plain(final BigDecimal v) { + return v.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static String voucherNo(final CommandProcessingResult posted) { + final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo"); + return voucherNo == null ? null : voucherNo.toString(); + } + + /** The figures of one closure. */ + private record Figures(BigDecimal expected, BigDecimal provisioned, BigDecimal received, BigDecimal tds, BigDecimal penal, + BigDecimal interest, BigDecimal income, BigDecimal renewalAmount, BigDecimal topUp) { + + Figures withPenal(final BigDecimal penalAmount) { + return new Figures(expected, provisioned, received, tds, penalAmount, interest, income, renewalAmount, topUp); + } + + InvestmentFdClosureData toData(final InvestmentFd f, final String type, final LocalDate date, final BigDecimal penalAmount, + final Entry e) { + return InvestmentFdClosureData.builder().fdId(f.getId()).dealNo(f.getDealNo()).type(type).closeDate(date.format(DATE)) + .principal(f.getPrincipal()).expectedInterest(expected).provisioned(provisioned).receivedAmount(received).tds(tds) + .penalAmount(penalAmount).interest(interest).income(income).difference(interest.subtract(expected)) + .renewalAmount(CLOSURE_RENEWAL.equals(type) ? renewalAmount : null).topUp(CLOSURE_RENEWAL.equals(type) ? topUp : null) + .lines(e.view).build(); + } + } + + /** Journal lines and the same lines for the screen. */ + private static final class Entry { + + private final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines(); + private final List view = new ArrayList<>(); + + void dr(final GLAccount gl, final BigDecimal amount) { + if (amount != null && amount.signum() > 0) { + this.lines.debit(gl.getId(), amount); + this.view.add(new InvestmentFdClosureData.Line("Dr", gl.getGlCode(), gl.getName(), amount)); + } + } + + void cr(final GLAccount gl, final BigDecimal amount) { + if (amount != null && amount.signum() > 0) { + this.lines.credit(gl.getId(), amount); + this.view.add(new InvestmentFdClosureData.Line("Cr", gl.getGlCode(), gl.getName(), amount)); + } + } + + boolean isEmpty() { + return this.view.isEmpty(); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdInterestService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdInterestService.java new file mode 100644 index 000000000..33e00c791 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdInterestService.java @@ -0,0 +1,327 @@ +/** + * 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.InvestmentFdApiConstants.BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_SIMPLE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INTEREST_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INTEREST_RECEIVABLE_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INT_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_MATURITY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_PAYOUT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_PROVISION; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.LINE_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT_AT_MATURITY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT_HALF_YEARLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.RECEIVED_AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.STATUS_ACTIVE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TDS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.TDS_RECEIVABLE_GL_CODE; + +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.Month; +import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.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.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.data.InvestmentFdDueData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdScheduleData; +import org.apache.fineract.portfolio.investment.domain.InvestmentFd; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterest; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdInterestRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Interest of an FD placed with another bank, as for Govt securities: on 31 March and 30 September the interest + * earned since the last payout (or the deal date) is provisioned (Dr Interest Receivable / Cr Interest on FDs); a + * half-yearly / yearly FD pays its interest every six / twelve months (Dr Bank, Dr TDS receivable when deducted / Cr + * Interest Receivable for what was provisioned, Cr Interest on FDs for the rest). Lines are posted in date order, on or + * after their date, dated that date. The interest of the last period comes with the maturity. + */ +@Service +@RequiredArgsConstructor +public class InvestmentFdInterestService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final FromJsonHelper fromApiJsonHelper; + private final InvestmentFdRepository fdRepository; + private final InvestmentFdInterestRepository interestRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final InvestmentJournalPoster journalPoster; + + /** One worked-out interest line. */ + record Line(String type, LocalDate date, LocalDate from, BigDecimal gross, BigDecimal alreadyBooked, BigDecimal tds, BigDecimal amount, + InvestmentFdInterest posted) {} + + @Transactional(readOnly = true) + public InvestmentFdScheduleData schedule(final Long fdId) { + final InvestmentFd f = this.fdRepository.findById(fdId).orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist")); + final List lines = lines(f); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final Optional next = lines.stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type())).findFirst(); + final List rows = new ArrayList<>(); + for (final Line l : lines) { + rows.add(InvestmentFdScheduleData.Row.builder().lineType(l.type()).intDate(l.date().format(DATE)).fromDate(l.from().format(DATE)) + .toDate(l.date().format(DATE)).days(ChronoUnit.DAYS.between(l.from(), l.date())).grossInterest(l.gross()) + .alreadyBooked(l.alreadyBooked()).tds(l.tds()).amount(l.amount()).posted(l.posted() != null) + .voucherNo(l.posted() == null ? null : l.posted().getVoucherNo()) + .glTransactionId(l.posted() == null ? null : l.posted().getGlTransactionId()) + .postable(STATUS_ACTIVE.equals(f.getStatus()) && next.isPresent() && next.get() == l && !l.date().isAfter(today)).build()); + } + return InvestmentFdScheduleData.builder().fd(InvestmentFdService.data(f)).provisioned(provisioned(f)).rows(rows).build(); + } + + /** For every active FD: its next interest voucher, or its maturity, when due now or within {@code withinDays}. */ + @Transactional(readOnly = true) + public List due(final int withinDays) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate upTo = today.plusDays(Math.max(withinDays, 0)); + final List due = new ArrayList<>(); + for (final InvestmentFd f : this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE)) { + lines(f).stream().filter(l -> l.posted() == null).findFirst().filter(l -> !l.date().isAfter(upTo)) + .ifPresent(l -> due.add(InvestmentFdDueData.builder().fdId(f.getId()).dealNo(f.getDealNo()) + .partyName(f.getParty().getPartyName()).certNo(f.getCertNo()).lineType(l.type()).intDate(l.date().format(DATE)) + .amount(LINE_MATURITY.equals(l.type()) ? f.getExpectedMaturityAmount() : l.amount()) + .postable(!l.date().isAfter(today)).overdue(l.date().isBefore(today)).build())); + } + return due; + } + + /** Refuses a closure on {@code date} while an interest line before it is still unposted. */ + void requirePostedBefore(final InvestmentFd f, final LocalDate date) { + lines(f).stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type()) && l.date().isBefore(date)).findFirst() + .ifPresent(l -> { + throw InvestmentFdService.rule("interest.pending", "Post the " + l.type().toLowerCase() + " of " + + l.date().format(DATE) + " for FD " + f.getDealNo() + " first (FD Interest Entry)"); + }); + } + + @Transactional + public CommandProcessingResult post(final Long fdId, final JsonCommand command) { + validate(command.json()); + final InvestmentFd f = this.fdRepository.findByIdWithLock(fdId) + .orElseThrow(() -> InvestmentFdService.rule("not.found", "FD " + fdId + " does not exist")); + if (!STATUS_ACTIVE.equals(f.getStatus())) { + throw InvestmentFdService.rule("not.active", "FD " + f.getDealNo() + " is already " + f.getStatus().toLowerCase()); + } + final String type = command.stringValueOfParameterNamed(LINE_TYPE); + final LocalDate date = command.localDateValueOfParameterNamed(INT_DATE); + if (date.isAfter(DateUtils.getBusinessLocalDate())) { + throw InvestmentFdService.rule("before.date", "This interest line can be posted on or after " + date.format(DATE)); + } + final Line next = lines(f).stream().filter(l -> l.posted() == null && !LINE_MATURITY.equals(l.type())).findFirst() + .orElseThrow(() -> InvestmentFdService.rule("all.posted", "FD " + f.getDealNo() + " has no interest line left to post; close it at maturity")); + if (!next.type().equals(type) || !next.date().equals(date)) { + throw InvestmentFdService.rule("out.of.order", "Post the interest lines in date order: next for FD " + f.getDealNo() + " is the " + + next.type().toLowerCase() + " of " + next.date().format(DATE)); + } + final GLAccount receivable = gl(INTEREST_RECEIVABLE_GL_CODE); + final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines(); + final InvestmentFdInterest x = new InvestmentFdInterest(); + x.setFd(f); + x.setLineType(type); + x.setIntDate(date); + x.setFromDate(next.from()); + x.setToDate(date); + x.setDays((int) ChronoUnit.DAYS.between(next.from(), date)); + final String what; + if (LINE_PROVISION.equals(type)) { + if (next.amount().signum() <= 0) { + throw InvestmentFdService.rule("nothing.to.provision", "No interest to provision on " + date.format(DATE)); + } + lines.debit(receivable.getId(), next.amount()).credit(f.getInterestGlAccount().getId(), next.amount()); + x.setGrossInterest(next.gross()); + x.setAmount(next.amount()); + what = "FD interest provision"; + } else { + // The bank's own figure when given (net of TDS), else the system's interest for the period. + final BigDecimal tds = amount(command, TDS); + final BigDecimal received = command.parameterExists(RECEIVED_AMOUNT) && command.bigDecimalValueOfParameterNamed(RECEIVED_AMOUNT) != null + ? amount(command, RECEIVED_AMOUNT) + : next.gross().subtract(tds); + final BigDecimal gross = received.add(tds); + final BigDecimal income = gross.subtract(next.alreadyBooked()); + final GLAccount bank = command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) == null ? f.getBankGlAccount() + : bankGl(command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID), f); + lines.debit(bank.getId(), received).debit(gl(TDS_RECEIVABLE_GL_CODE).getId(), tds).credit(receivable.getId(), next.alreadyBooked()); + if (income.signum() >= 0) { + lines.credit(f.getInterestGlAccount().getId(), income); + } else { + lines.debit(f.getInterestGlAccount().getId(), income.negate()); + } + x.setGrossInterest(gross); + x.setAlreadyBooked(next.alreadyBooked()); + x.setTds(tds); + x.setAmount(income); + x.setBankGlAccount(bank); + what = "FD interest received"; + } + final CommandProcessingResult posted = this.journalPoster.post(date, f.getDealNo(), what + " " + date.format(DATE) + ": deal " + + f.getDealNo() + ", certificate " + f.getCertNo() + " with " + f.getParty().getPartyName(), lines); + x.setGlTransactionId(posted.getTransactionId()); + final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo"); + x.setVoucherNo(voucherNo == null ? null : voucherNo.toString()); + x.setPostedOnDate(DateUtils.getBusinessLocalDate()); + final InvestmentFdInterest saved = this.interestRepository.saveAndFlush(x); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()).withSubEntityId(f.getId()) + .withResourceIdAsString(saved.getVoucherNo()).withTransactionId(posted.getTransactionId()).build(); + } + + // -------------------------------------------------------------------------------------------------- schedule + + /** Every interest line of the FD in date order, with the posted figures where posted and the worked-out ones otherwise. */ + List lines(final InvestmentFd f) { + final List posted = this.interestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId()); + final LocalDate end = f.getClosedOnDate() != null ? f.getClosedOnDate() : f.getMaturityDate(); + final List events = new ArrayList<>(); + if (!PAYOUT_AT_MATURITY.equals(f.getPayout())) { + final int months = PAYOUT_HALF_YEARLY.equals(f.getPayout()) ? 6 : 12; + for (int k = 1;; k++) { + final LocalDate d = f.getDealDate().plusMonths((long) months * k); + if (!d.isBefore(f.getMaturityDate())) { + break; + } + events.add(new Object[] { d, LINE_PAYOUT }); + } + } + for (int y = f.getDealDate().getYear(); y <= f.getMaturityDate().getYear(); y++) { + for (final LocalDate d : List.of(LocalDate.of(y, Month.MARCH, 31), LocalDate.of(y, Month.SEPTEMBER, 30))) { + if (d.isAfter(f.getDealDate()) && d.isBefore(f.getMaturityDate())) { + events.add(new Object[] { d, LINE_PROVISION }); + } + } + } + // A payout falling on a provision date comes first: the provision then starts from the payout. + events.sort(Comparator.comparing(e -> (LocalDate) e[0]).thenComparing(e -> LINE_PAYOUT.equals(e[1]) ? 0 : 1)); + + final List out = new ArrayList<>(); + LocalDate lastPaid = f.getDealDate(); + BigDecimal sincePaid = BigDecimal.ZERO; + for (final Object[] e : events) { + final LocalDate d = (LocalDate) e[0]; + final String type = (String) e[1]; + if (d.isAfter(end)) { + break; + } + final InvestmentFdInterest done = posted.stream().filter(p -> p.getLineType().equals(type) && p.getIntDate().equals(d)).findFirst() + .orElse(null); + if (LINE_PROVISION.equals(type)) { + final BigDecimal accrued = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, d, f.getCalcType(), + f.getPayout()); + final BigDecimal amount = done != null ? done.getAmount() : accrued.subtract(sincePaid).max(BigDecimal.ZERO); + if (done == null && amount.signum() == 0) { + continue; + } + out.add(new Line(type, d, lastPaid, done != null ? done.getGrossInterest() : accrued, null, null, amount, done)); + sincePaid = sincePaid.add(amount); + } else { + final BigDecimal gross = done != null ? done.getGrossInterest() + : InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, d, CALC_SIMPLE, f.getPayout()); + final BigDecimal booked = done != null ? zero(done.getAlreadyBooked()) : sincePaid; + out.add(new Line(type, d, lastPaid, gross, booked, done == null ? null : done.getTds(), + done != null ? done.getAmount() : gross.subtract(booked), done)); + lastPaid = d; + sincePaid = BigDecimal.ZERO; + } + } + if (f.getClosedOnDate() == null) { + final BigDecimal last = InvestmentFdCalculator.interest(f.getPrincipal(), f.getIntRate(), lastPaid, f.getMaturityDate(), + f.getCalcType(), f.getPayout()); + out.add(new Line(LINE_MATURITY, f.getMaturityDate(), lastPaid, last, sincePaid, null, last.subtract(sincePaid), null)); + } + return out; + } + + /** Interest provisioned and not yet received. */ + BigDecimal provisioned(final InvestmentFd f) { + BigDecimal v = BigDecimal.ZERO; + for (final InvestmentFdInterest l : this.interestRepository.findByFdIdOrderByIntDateAscIdAsc(f.getId())) { + v = LINE_PROVISION.equals(l.getLineType()) ? v.add(l.getAmount()) : v.subtract(zero(l.getAlreadyBooked())); + } + return v.max(BigDecimal.ZERO); + } + + private GLAccount bankGl(final Long glId, final InvestmentFd f) { + final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glId); + if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType()) + || gl.getId().equals(f.getFdGlAccount().getId())) { + throw InvestmentFdService.rule("bank.gl.invalid", "GL " + gl.getGlCode() + " must be our bank account (an enabled detail Asset GL)"); + } + return gl; + } + + private GLAccount gl(final String code) { + return this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(code); + } + + private static BigDecimal amount(final JsonCommand command, final String param) { + final BigDecimal v = command.parameterExists(param) ? command.bigDecimalValueOfParameterNamed(param) : null; + return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP); + } + + private static BigDecimal zero(final BigDecimal v) { + return v == null ? BigDecimal.ZERO : v; + } + + private void validate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, INTEREST_PARAMS); + final JsonElement e = this.fromApiJsonHelper.parse(json); + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource("investmentfd"); + v.reset().parameter(LINE_TYPE).value(this.fromApiJsonHelper.extractStringNamed(LINE_TYPE, e)).notBlank() + .isOneOfTheseValues(LINE_PROVISION, LINE_PAYOUT); + v.reset().parameter(INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(INT_DATE, e)).notNull(); + v.reset().parameter(RECEIVED_AMOUNT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(RECEIVED_AMOUNT, e)).ignoreIfNull() + .zeroOrPositiveAmount(); + v.reset().parameter(TDS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TDS, e)).ignoreIfNull().zeroOrPositiveAmount(); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", 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 new file mode 100644 index 000000000..63b602911 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFdService.java @@ -0,0 +1,386 @@ +/** + * 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.InvestmentFdApiConstants.AMOUNT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.AS_ON_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.BANK_BALANCE_HEADER_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.BANK_LIMIT_PERCENT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.BASE_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.BOND_TYPE_FD; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_QUARTERLY; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CALC_TYPES; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CERT_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.CERT_NO; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.DEAL_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.DEPOSIT_HEADER_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.FD_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.GROSS_LIMIT_PERCENT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.INT_RATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.MATURITY_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PARTY_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUTS; +import static org.apache.fineract.portfolio.investment.api.InvestmentFdApiConstants.PAYOUT_AT_MATURITY; +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.TOTAL_DEPOSITS; + +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.Month; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.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.data.InvestmentFdData; +import org.apache.fineract.portfolio.investment.data.InvestmentFdLimitData; +import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBase; +import org.apache.fineract.portfolio.investment.domain.InvestmentDepositBaseRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentFd; +import org.apache.fineract.portfolio.investment.domain.InvestmentFdRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentParty; +import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; +import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException; +import org.springframework.data.domain.Sort; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * FDs the bank places with other banks (Jitendra Sir): the bank is an FD party with its own FD GL and an interest GL; + * placing the money posts Dr FD GL / Cr our bank GL at once, dated the deal date. RBI limits: deposits with other + * banks (current accounts included) within 20% of total deposits as on the previous 31 March, and within 5% with any + * one bank. + */ +@Service +@RequiredArgsConstructor +public class InvestmentFdService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final FromJsonHelper fromApiJsonHelper; + private final InvestmentFdRepository fdRepository; + private final InvestmentDepositBaseRepository depositBaseRepository; + private final InvestmentPartyRepository partyRepository; + private final GLAccountRepositoryWrapper glAccountRepository; + private final InvestmentJournalPoster journalPoster; + private final JdbcTemplate jdbcTemplate; + + // -------------------------------------------------------------------------------------------------- reads + + @Transactional(readOnly = true) + public List retrieveAll() { + return this.fdRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentFdService::data).toList(); + } + + @Transactional(readOnly = true) + public InvestmentFdData retrieveOne(final Long fdId) { + return data(this.fdRepository.findById(fdId).orElseThrow(() -> rule("not.found", "FD " + fdId + " does not exist"))); + } + + /** What an FD would earn and where it stands against the RBI limits, before it is saved. */ + @Transactional(readOnly = true) + public InvestmentFdData quote(final Long partyId, final BigDecimal amount, final BigDecimal rate, final LocalDate dealDate, + final LocalDate maturityDate, final String payout, final String calcType) { + // Set.of(...).contains(null) throws, so blank choices fall back to the defaults first. + final String pay = payout != null && PAYOUTS.contains(payout) ? payout : PAYOUT_AT_MATURITY; + final String calc = calcType != null && CALC_TYPES.contains(calcType) ? calcType : CALC_QUARTERLY; + final BigDecimal interest = maturityDate == null || dealDate == null ? BigDecimal.ZERO + : InvestmentFdCalculator.interest(amount, rate, dealDate, maturityDate, calc, pay); + final InvestmentParty party = partyId == null ? null : this.partyRepository.findById(partyId).orElse(null); + return InvestmentFdData.builder().partyId(partyId).partyName(party == null ? null : party.getPartyName()) + .principal(amount).intRate(rate).dealDate(format(dealDate)).maturityDate(format(maturityDate)) + .days(dealDate == null || maturityDate == null ? 0 : java.time.temporal.ChronoUnit.DAYS.between(dealDate, maturityDate)) + .payout(pay).calcType(calc).expectedInterest(interest).expectedMaturityAmount(maturityAmount(amount, interest, pay)) + .periodInterest(InvestmentFdCalculator.periodInterest(amount, rate, pay)) + .fdGlCode(party == null ? null : party.getGlAccount().getGlCode()) + .fdGlName(party == null ? null : party.getGlAccount().getName()) + .interestGlCode(party == null || party.getInterestGlAccount() == null ? null : party.getInterestGlAccount().getGlCode()) + .interestGlName(party == null || party.getInterestGlAccount() == null ? null : party.getInterestGlAccount().getName()) + .limit(limits(dealDate == null ? DateUtils.getBusinessLocalDate() : dealDate, party, amount)).build(); + } + + /** The RBI limits for an FD of {@code amount} with {@code party} (either may be empty) on {@code dealDate}. */ + @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 BigDecimal newAmount = amount == null ? BigDecimal.ZERO : amount; + final BigDecimal fdOutstanding = this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE).stream().map(InvestmentFd::getPrincipal) + .reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal bankBalances = bankBalances(); + final BigDecimal withParty = party == null ? BigDecimal.ZERO + : this.fdRepository.findByStatusOrderByIdAsc(STATUS_ACTIVE).stream().filter(f -> f.getParty().getId().equals(party.getId())) + .map(InvestmentFd::getPrincipal).reduce(BigDecimal.ZERO, BigDecimal::add); + 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) + .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) + + " first; the RBI limits on deposits with other banks are worked out from it").build(); + } + final BigDecimal grossLimit = percent(total, GROSS_LIMIT_PERCENT); + final BigDecimal bankLimit = percent(total, BANK_LIMIT_PERCENT); + b.grossLimit(grossLimit).bankLimit(bankLimit); + if (grossAfter.compareTo(grossLimit) > 0) { + return b.withinLimits(false).message("Deposits with other banks would be " + plain(grossAfter) + ", above the RBI limit of " + + plain(grossLimit) + " (20% of total deposits " + plain(total) + " as on " + base.format(DATE) + ")").build(); + } + if (party != null && bankAfter.compareTo(bankLimit) > 0) { + return b.withinLimits(false).message("Deposits with " + party.getPartyName() + " would be " + plain(bankAfter) + + ", above the RBI limit of " + plain(bankLimit) + " with one bank (5% of total deposits " + plain(total) + ")") + .build(); + } + return b.withinLimits(true).message("Within the RBI limits").build(); + } + + // -------------------------------------------------------------------------------------------------- writes + + /** Places an FD: Dr the bank's FD GL / Cr our bank GL, dated the deal date, voucher raised at once. */ + @Transactional + public CommandProcessingResult create(final JsonCommand command) { + validateFd(command.json()); + final Long partyId = command.longValueOfParameterNamed(PARTY_ID); + final InvestmentParty party = this.partyRepository.findByIdWithLock(partyId) + .orElseThrow(() -> new InvestmentPartyNotFoundException(partyId)); + if (!BOND_TYPE_FD.equals(party.getBondType())) { + throw rule("party.not.fd", "Party " + party.getPartyCode() + " is not an FD bank (Bond Type " + party.getBondType() + ")"); + } + if (!party.isActive()) { + throw rule("party.inactive", "Party " + party.getPartyCode() + " is inactive"); + } + if (party.getInterestGlAccount() == null) { + throw rule("party.no.interest.gl", "Map the interest GL of " + party.getPartyName() + " in Bank / Party Master first"); + } + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate dealDate = command.localDateValueOfParameterNamed(DEAL_DATE); + final LocalDate maturityDate = command.localDateValueOfParameterNamed(MATURITY_DATE); + if (dealDate.isAfter(today)) { + throw rule("deal.future", "Deal date cannot be after today"); + } + if (!maturityDate.isAfter(dealDate)) { + throw rule("maturity.before.deal", "Maturity date must be after the deal date"); + } + final String certNo = command.stringValueOfParameterNamed(CERT_NO).trim(); + if (this.fdRepository.existsByPartyIdAndCertNoIgnoreCase(partyId, certNo)) { + throw rule("cert.duplicate", "Certificate " + certNo + " of " + party.getPartyName() + " is already entered"); + } + final GLAccount bankGl = postableAsset(command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID)); + if (bankGl.getId().equals(party.getGlAccount().getId())) { + throw rule("bank.gl.same", "The money cannot be paid from the FD GL itself; choose our bank account"); + } + final BigDecimal amount = command.bigDecimalValueOfParameterNamed(AMOUNT).setScale(2, RoundingMode.HALF_UP); + final InvestmentFdLimitData limit = limits(dealDate, party, amount); + if (!limit.isWithinLimits()) { + throw rule("limit.exceeded", limit.getMessage()); + } + + final InvestmentFd f = new InvestmentFd(); + f.setDealNo(nextDealNo(dealDate)); + f.setParty(party); + f.setFdGlAccount(party.getGlAccount()); + f.setInterestGlAccount(party.getInterestGlAccount()); + f.setCertNo(certNo); + f.setCertDate(command.localDateValueOfParameterNamed(CERT_DATE)); + f.setDealDate(dealDate); + f.setPrincipal(amount); + f.setIntRate(command.bigDecimalValueOfParameterNamed(INT_RATE)); + f.setMaturityDate(maturityDate); + f.setPayout(command.stringValueOfParameterNamed(PAYOUT)); + f.setCalcType(StringUtils.defaultIfBlank(command.stringValueOfParameterNamed(CALC_TYPE), CALC_QUARTERLY)); + final BigDecimal interest = InvestmentFdCalculator.interest(amount, f.getIntRate(), dealDate, maturityDate, f.getCalcType(), + f.getPayout()); + f.setExpectedInterest(interest); + f.setExpectedMaturityAmount(maturityAmount(amount, interest, f.getPayout())); + f.setBankGlAccount(bankGl); + f.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS))); + f.setStatus(STATUS_ACTIVE); + + final CommandProcessingResult posted = this.journalPoster.post(dealDate, f.getDealNo(), + "FD placed: deal " + f.getDealNo() + " with " + party.getPartyName() + ", certificate " + certNo + ", " + + f.getIntRate().stripTrailingZeros().toPlainString() + "% to " + maturityDate.format(DATE), + new InvestmentJournalPoster.Lines().debit(party.getGlAccount().getId(), amount).credit(bankGl.getId(), amount)); + f.setGlTransactionId(posted.getTransactionId()); + f.setVoucherNo(voucherNo(posted)); + final InvestmentFd saved = this.fdRepository.saveAndFlush(f); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) + .withResourceIdAsString(saved.getVoucherNo()).withTransactionId(posted.getTransactionId()) + .with(Map.of("dealNo", saved.getDealNo(), "voucherNo", String.valueOf(saved.getVoucherNo()))).build(); + } + + /** Records the bank's total deposits as on a 31 March, the base of the RBI limits for the following year. */ + @Transactional + public CommandProcessingResult setDepositBase(final JsonCommand command) { + 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() + .positiveAmount(); + v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, e)).ignoreIfNull().notExceedingLengthOf(500); + }); + final LocalDate asOn = command.localDateValueOfParameterNamed(AS_ON_DATE); + if (asOn.getMonth() != Month.MARCH || asOn.getDayOfMonth() != 31) { + throw rule("base.not.yearend", "Total deposits are taken as on 31 March"); + } + final InvestmentDepositBase base = this.depositBaseRepository.findByAsOnDate(asOn).orElseGet(InvestmentDepositBase::new); + base.setAsOnDate(asOn); + base.setTotalDeposits(command.bigDecimalValueOfParameterNamed(TOTAL_DEPOSITS)); + base.setRemarks(StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS))); + final InvestmentDepositBase saved = this.depositBaseRepository.saveAndFlush(base); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) + .withResourceIdAsString(asOn.format(DATE)).build(); + } + + // -------------------------------------------------------------------------------------------------- helpers + + static InvestmentFdData data(final InvestmentFd f) { + return InvestmentFdData.from(f, InvestmentFdCalculator.periodInterest(f.getPrincipal(), f.getIntRate(), f.getPayout())); + } + + static BigDecimal maturityAmount(final BigDecimal amount, final BigDecimal interest, final String payout) { + if (amount == null) { + return null; + } + // A payout FD returns only the principal at maturity (with the last period's interest, posted as a payout). + return PAYOUT_AT_MATURITY.equals(payout) ? amount.add(interest) : amount; + } + + /** The 31 March of the previous financial year: for 16-10-2026 → 31-03-2026, for 31-03-2026 → 31-03-2025. */ + static LocalDate previousYearEnd(final LocalDate date) { + final int year = date.getMonthValue() >= 4 ? date.getYear() : date.getYear() - 1; + return LocalDate.of(year, Month.MARCH, 31); + } + + /** Balances in current accounts with banks today (debit balances only), part of the inter-bank exposure. */ + private BigDecimal bankBalances() { + final BigDecimal v = this.jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(GREATEST(t.bal, 0)), 0) FROM ( + SELECT SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE -je.amount END) AS bal + FROM acc_gl_journal_entry je JOIN acc_gl_account a ON a.id = je.account_id + WHERE a.parent_id = (SELECT id FROM acc_gl_account WHERE gl_code = ?) AND je.reversed = false + GROUP BY je.account_id) t""", BigDecimal.class, BANK_BALANCE_HEADER_GL_CODE); + return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP); + } + + /** Total deposits on a date from the deposit GLs (credit balances), shown as a suggestion for the yearly figure. */ + private BigDecimal glDeposits(final LocalDate asOn) { + final BigDecimal v = this.jdbcTemplate.queryForObject(""" + SELECT COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE -je.amount END), 0) + FROM acc_gl_journal_entry je JOIN acc_gl_account a ON a.id = je.account_id + WHERE (a.parent_id = (SELECT id FROM acc_gl_account WHERE gl_code = ?) OR a.gl_code = ?) + AND je.reversed = false AND je.entry_date <= ?""", BigDecimal.class, DEPOSIT_HEADER_GL_CODE, RD_DEPOSIT_GL_CODE, asOn); + return v == null ? BigDecimal.ZERO : v.setScale(2, RoundingMode.HALF_UP); + } + + String nextDealNo(final LocalDate dealDate) { + 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, + prefix + "%"); + return prefix + String.format("%05d", (max == null ? 0 : max) + 1); + } + + private GLAccount postableAsset(final Long glAccountId) { + final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) { + throw rule("bank.gl.invalid", "GL " + gl.getGlCode() + " must be an enabled detail Asset account (our bank account)"); + } + return gl; + } + + private void validateFd(final String json) { + validate(json, FD_PARAMS, v -> { + final JsonElement e = this.fromApiJsonHelper.parse(json); + v.reset().parameter(PARTY_ID).value(this.fromApiJsonHelper.extractLongNamed(PARTY_ID, e)).notNull().longGreaterThanZero(); + v.reset().parameter(CERT_NO).value(this.fromApiJsonHelper.extractStringNamed(CERT_NO, e)).notBlank().notExceedingLengthOf(50); + v.reset().parameter(DEAL_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(DEAL_DATE, e)).notNull(); + v.reset().parameter(MATURITY_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(MATURITY_DATE, e)).notNull(); + v.reset().parameter(AMOUNT).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(AMOUNT, e)).notNull().positiveAmount(); + v.reset().parameter(INT_RATE).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(INT_RATE, e)).notNull() + .positiveAmount(); + v.reset().parameter(PAYOUT).value(this.fromApiJsonHelper.extractStringNamed(PAYOUT, e)).notBlank() + .isOneOfTheseValues(PAYOUTS.toArray()); + v.reset().parameter(CALC_TYPE).value(this.fromApiJsonHelper.extractStringNamed(CALC_TYPE, e)).ignoreIfNull() + .isOneOfTheseValues(CALC_TYPES.toArray()); + v.reset().parameter(BANK_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(BANK_GL_ACCOUNT_ID, e)).notNull() + .longGreaterThanZero(); + v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, e)).ignoreIfNull().notExceedingLengthOf(500); + }); + } + + private void validate(final String json, final Set supported, final java.util.function.Consumer rules) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, supported); + final List errors = new ArrayList<>(); + rules.accept(new DataValidatorBuilder(errors).resource("investmentfd")); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static BigDecimal percent(final BigDecimal total, final BigDecimal pct) { + return total.multiply(pct).divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP); + } + + private static String plain(final BigDecimal v) { + return v.setScale(2, RoundingMode.HALF_UP).toPlainString(); + } + + private static String format(final LocalDate date) { + return date == null ? null : date.format(DATE); + } + + private static String voucherNo(final CommandProcessingResult posted) { + final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo"); + return voucherNo == null ? null : voucherNo.toString(); + } + + static GeneralPlatformDomainRuleException rule(final String code, final String message) { + return new GeneralPlatformDomainRuleException("error.msg.investmentfd." + code, message); + } +} 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 2d0be5fa8..833c6cb50 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,6 +35,7 @@ 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.INTEREST_GL_ACCOUNT_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.INT_RATE; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISIN; import static org.apache.fineract.portfolio.investment.api.InvestmentApiConstants.ISSUE_DATE; @@ -155,6 +156,20 @@ public class InvestmentMasterWritePlatformServiceImpl implements InvestmentMaste if (command.parameterExists(GL_ACCOUNT_ID)) { p.setGlAccount(postableAssetGl(command.longValueOfParameterNamed(GL_ACCOUNT_ID))); } + if (command.parameterExists(INTEREST_GL_ACCOUNT_ID)) { + final Long interestGlId = command.longValueOfParameterNamed(INTEREST_GL_ACCOUNT_ID); + p.setInterestGlAccount(interestGlId == null ? null : postableIncomeGl(interestGlId)); + } + } + + /** Interest on an FD with this bank is credited here, so it must be a usable Income account. */ + private GLAccount postableIncomeGl(final Long glAccountId) { + final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.INCOME.getValue().equals(gl.getType())) { + throw new GeneralPlatformDomainRuleException("error.msg.investment.glaccount.not.income", + "GL " + gl.getGlCode() + " must be an enabled detail Income account", glAccountId); + } + return gl; } private InvestmentParty saveParty(final InvestmentParty party) { 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 f3dc17cba..3ca2ec20c 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 @@ -183,4 +183,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_investment_fd.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_investment_fd.xml new file mode 100644 index 000000000..bb9d91983 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0604_investment_fd.xml @@ -0,0 +1,217 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE entity_name = 'INVESTMENTFD' + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES + ('portfolio', 'READ_INVESTMENTFD', 'INVESTMENTFD', 'READ', FALSE), + ('portfolio', 'CREATE_INVESTMENTFD', 'INVESTMENTFD', 'CREATE', TRUE), + ('portfolio', 'CREATE_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'CREATE_CHECKER', FALSE), + ('portfolio', 'SETBASE_INVESTMENTFD', 'INVESTMENTFD', 'SETBASE', FALSE), + ('portfolio', 'SETBASE_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'SETBASE_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 IN ('READ_INVESTMENTFD', 'CREATE_INVESTMENTFD', 'SETBASE_INVESTMENTFD') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'MATURE_INVESTMENTFD' + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES + ('portfolio', 'MATURE_INVESTMENTFD', 'INVESTMENTFD', 'MATURE', TRUE), + ('portfolio', 'MATURE_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'MATURE_CHECKER', FALSE), + ('portfolio', 'PREMATURE_INVESTMENTFD', 'INVESTMENTFD', 'PREMATURE', TRUE), + ('portfolio', 'PREMATURE_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'PREMATURE_CHECKER', FALSE), + ('portfolio', 'RENEW_INVESTMENTFD', 'INVESTMENTFD', 'RENEW', TRUE), + ('portfolio', 'RENEW_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'RENEW_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 IN ('MATURE_INVESTMENTFD', 'PREMATURE_INVESTMENTFD', 'RENEW_INVESTMENTFD') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'POSTINTEREST_INVESTMENTFD' + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES + ('portfolio', 'POSTINTEREST_INVESTMENTFD', 'INVESTMENTFD', 'POSTINTEREST', FALSE), + ('portfolio', 'POSTINTEREST_INVESTMENTFD_CHECKER', 'INVESTMENTFD', 'POSTINTEREST_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 = 'POSTINTEREST_INVESTMENTFD' + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + +