From 8db4fc7fee22e595f1d07a3eabe303afea67685c Mon Sep 17 00:00:00 2001 From: anshul Date: Tue, 27 Oct 2026 15:03:38 +0530 Subject: [PATCH] feat(investment): MTM valuation with IDR and mutual fund switch Co-Authored-By: Claude Opus 5.5 --- .../service/CommandWrapperBuilder.java | 16 + .../api/InvestmentPurchaseApiResource.java | 12 + .../api/InvestmentSaleApiConstants.java | 9 + .../api/InvestmentValuationApiConstants.java | 49 +++ .../api/InvestmentValuationApiResource.java | 92 +++++ .../data/InvestmentValuationData.java | 85 +++++ .../domain/InvestmentPurchaseRepository.java | 2 + .../domain/InvestmentValuation.java | 74 ++++ .../domain/InvestmentValuationLine.java | 75 ++++ .../InvestmentValuationLineRepository.java | 27 ++ .../domain/InvestmentValuationRepository.java | 31 ++ .../InvestmentValuationNotFoundException.java | 28 ++ ...eateInvestmentValuationCommandHandler.java | 42 +++ .../SwitchInvestmentFundCommandHandler.java | 42 +++ ...mentDailyRateWritePlatformServiceImpl.java | 10 +- .../service/InvestmentFundSwitchService.java | 270 ++++++++++++++ ...tmentPurchaseWritePlatformServiceImpl.java | 17 +- .../service/InvestmentValuationService.java | 346 ++++++++++++++++++ .../tenant/final-changelog-tenant.xml | 1 + ...0_investment_valuation_and_fund_switch.xml | 114 ++++++ 20 files changed, 1332 insertions(+), 10 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiResource.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuation.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLine.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLineRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationRepository.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/exception/InvestmentValuationNotFoundException.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentValuationCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SwitchInvestmentFundCommandHandler.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0610_investment_valuation_and_fund_switch.xml 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 e27504eea..d74b7d64a 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 @@ -255,6 +255,22 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder switchInvestmentFund(final Long purchaseId) { + this.actionName = "SWITCH"; + this.entityName = "INVESTMENTSALE"; + this.entityId = purchaseId; + this.href = "/investment/purchases/" + purchaseId + "/switch"; + return this; + } + + public CommandWrapperBuilder createInvestmentValuation() { + this.actionName = "CREATE"; + this.entityName = "INVESTMENTVALUATION"; + this.entityId = null; + this.href = "/investment/valuations"; + return this; + } + public CommandWrapperBuilder transferInvestmentSecurity(final Long purchaseId) { this.actionName = "CREATE"; this.entityName = "INVESTMENTTRANSFER"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java index 78ef4f90e..c990958ba 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java @@ -189,6 +189,18 @@ public class InvestmentPurchaseApiResource { return this.commandsSourceWritePlatformService.logCommandSource(request); } + @POST + @Path("{purchaseId}/switch") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Mutual fund switch", description = "switchDate, units, fromNav, toScriptId, toNav, subType, remarks: the units " + + "leave this fund at fromNav and a new deal opens in the other scheme the same day; no bank leg.") + public CommandProcessingResult switchFund(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().switchInvestmentFund(purchaseId).withJson(apiRequestBodyAsJson).build(); + return this.commandsSourceWritePlatformService.logCommandSource(request); + } + @DELETE @Path("{purchaseId}") @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java index 34ece32b7..87f3cfaa5 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java @@ -34,6 +34,8 @@ public final class InvestmentSaleApiConstants { public static final String TYPE_WRITEOFF = "WRITEOFF"; /** A year-end amortisation of premium (31 March); recorded like a sale so the book value comes down. */ public static final String TYPE_AMORTISE = "AMORTISE"; + /** Units of a mutual fund switched at NAV into another scheme the same day; no money comes to the bank. */ + public static final String TYPE_SWITCH = "SWITCH"; public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT"; public static final String STATUS_SETTLED = "SETTLED"; @@ -59,6 +61,11 @@ public final class InvestmentSaleApiConstants { public static final String REMARKS = "remarks"; public static final String AMOUNT = "amount"; public static final String FY_END = "fyEnd"; + public static final String SWITCH_DATE = "switchDate"; + public static final String FROM_NAV = "fromNav"; + public static final String TO_SCRIPT_ID = "toScriptId"; + public static final String TO_NAV = "toNav"; + public static final String SUB_TYPE = "subType"; public static final String LOCALE = "locale"; public static final String DATE_FORMAT = "dateFormat"; @@ -67,4 +74,6 @@ public final class InvestmentSaleApiConstants { public static final Set REDEEM_PARAMS = Set.of(BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); public static final Set WRITEOFF_PARAMS = Set.of(AMOUNT, REMARKS, LOCALE, DATE_FORMAT); public static final Set AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT); + public static final Set SWITCH_PARAMS = Set.of(SWITCH_DATE, UNITS, FROM_NAV, TO_SCRIPT_ID, TO_NAV, SUB_TYPE, REMARKS, LOCALE, + DATE_FORMAT); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java new file mode 100644 index 000000000..569372738 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiConstants.java @@ -0,0 +1,49 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.api; + +import java.util.Set; + +/** Market valuation (MTM) of AFS and HFT holdings, with net depreciation held in the Investment Depreciation Reserve. */ +public final class InvestmentValuationApiConstants { + + private InvestmentValuationApiConstants() {} + + public static final String ENTITY = "INVESTMENTVALUATION"; + public static final String RESOURCE = "investmentvaluation"; + + /** Categories that are marked to market: HFT monthly, AFS quarterly (or more often). HTM is carried at cost. */ + public static final Set CATEGORIES = Set.of("AFS", "HFT"); + + /** Classes within which gains and losses are netted; one class never offsets another. */ + public static final String CLASS_GOVT = "GOVT_SECURITY"; + public static final String CLASS_BOND = "CORPORATE_BOND"; + public static final String CLASS_FUND = "MUTUAL_FUND"; + + /** Depreciation charged to P&L (Provisions & Contingencies) and the reserve it is held in. */ + public static final String DEPRECIATION_PROVISION_GL_CODE = "500504"; + public static final String DEPRECIATION_RESERVE_GL_CODE = "200205"; + + public static final String VALUATION_DATE = "valuationDate"; + public static final String CATEGORY = "category"; + public static final String LOCALE = "locale"; + public static final String DATE_FORMAT = "dateFormat"; + + public static final Set RUN_PARAMS = Set.of(VALUATION_DATE, CATEGORY, LOCALE, DATE_FORMAT); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiResource.java new file mode 100644 index 000000000..95d71c40b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentValuationApiResource.java @@ -0,0 +1,92 @@ +/** + * 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.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.domain.CommandWrapper; +import org.apache.fineract.commands.service.CommandWrapperBuilder; +import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.investment.data.InvestmentValuationData; +import org.apache.fineract.portfolio.investment.service.InvestmentValuationService; +import org.springframework.stereotype.Component; + +@Path("/v1/investment/valuations") +@Component +@Tag(name = "Investment Valuation", description = "Market valuation (MTM) of AFS / HFT holdings; net depreciation held in the IDR.") +@RequiredArgsConstructor +public class InvestmentValuationApiResource { + + private final PlatformSecurityContext context; + private final InvestmentValuationService valuationService; + private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService; + + @GET + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Valuation runs, newest first") + public List retrieveAll() { + this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY); + return this.valuationService.retrieveAll(); + } + + @GET + @Path("preview") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "What a valuation would find and post, without saving", description = "valuationDate dd-MM-yyyy (default " + + "today), category AFS or HFT") + public InvestmentValuationData preview(@QueryParam("valuationDate") @Parameter(description = "dd-MM-yyyy") final String valuationDate, + @QueryParam("category") @Parameter(description = "AFS or HFT") final String category) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY); + return this.valuationService.preview(InvestmentApiDates.parse(valuationDate, DateUtils.getBusinessLocalDate(), "valuation date"), + category); + } + + @GET + @Path("{valuationId}") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "One valuation run with its security-wise lines") + public InvestmentValuationData retrieveOne(@PathParam("valuationId") @Parameter(description = "valuationId") final Long valuationId) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentValuationApiConstants.ENTITY); + return this.valuationService.retrieveOne(valuationId); + } + + @POST + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Run and post a valuation", description = "valuationDate, category (AFS / HFT). Posts the change in IDR: " + + "Dr 500504 / Cr 200205 when it grows, the reverse when it falls.") + public CommandProcessingResult run(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().createInvestmentValuation().withJson(apiRequestBodyAsJson).build(); + return this.commandsSourceWritePlatformService.logCommandSource(request); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java new file mode 100644 index 000000000..5691e2116 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentValuationData.java @@ -0,0 +1,85 @@ +/** + * 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; + +/** + * A market valuation (MTM) of AFS or HFT holdings: a preview before posting (no id) or a posted run. Each security is + * valued at the Daily Script Master rate; the IDR needed per class is its net loss, a net gain is ignored. + */ +@Getter +@Builder +public class InvestmentValuationData { + + private final Long id; + private final String valuationDate; + private final String category; + private final BigDecimal requiredTotal; + /** IDR held from the category's previous run. */ + private final BigDecimal previousTotal; + /** Posted (or to be posted): more provision when positive, a write-back when negative. */ + private final BigDecimal delta; + private final String provisionGlCode; + private final String reserveGlCode; + private final String glTransactionId; + private final String voucherNo; + /** Date of the category's last run, if any; a new run must be dated after it. */ + private final String lastRunDate; + /** Scripts held with no rate on or before the valuation date: the run is refused until they are entered. */ + private final List missingRates; + /** A preview can be posted: no rate missing, dated after the last run, and something to value or to write back. */ + private final boolean postable; + private final List classes; + private final List lines; + + @Getter + @Builder + public static class ClassTotal { + + /** GOVT_SECURITY, CORPORATE_BOND or MUTUAL_FUND. */ + private final String assetClass; + private final BigDecimal bookValue; + private final BigDecimal marketValue; + private final BigDecimal netDifference; + private final BigDecimal required; + } + + @Getter + @Builder + public static class Line { + + private final Long purchaseId; + private final String dealNo; + private final String certNo; + private final String bondType; + private final String scriptCode; + private final String scriptDesc; + private final String assetClass; + private final BigDecimal units; + private final BigDecimal bookValue; + private final BigDecimal rate; + private final String rateDate; + private final BigDecimal marketValue; + private final BigDecimal difference; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java index 94c81b73f..5bb49d2a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchaseRepository.java @@ -33,4 +33,6 @@ public interface InvestmentPurchaseRepository extends JpaRepository findByIdWithLock(@Param("purchaseId") Long purchaseId); boolean existsByScriptIdAndStatusNot(Long scriptId, String status); + + boolean existsByScriptIdAndStatus(Long scriptId, String status); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuation.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuation.java new file mode 100644 index 000000000..f7ed9bf8a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuation.java @@ -0,0 +1,74 @@ +/** + * 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; + +/** + * One market valuation (MTM) of a category's holdings on a date: the IDR it needs per class and in total, what the + * previous run held, and the difference posted to GL. + */ +@Entity +@Table(name = "m_investment_valuation") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentValuation extends AbstractAuditableWithUTCDateTimeCustom { + + @Column(name = "valuation_date", nullable = false) + private LocalDate valuationDate; + + /** AFS or HFT. */ + @Column(name = "category", nullable = false, length = 10) + private String category; + + @Column(name = "govt_required", scale = 6, precision = 19, nullable = false) + private BigDecimal govtRequired; + + @Column(name = "bond_required", scale = 6, precision = 19, nullable = false) + private BigDecimal bondRequired; + + @Column(name = "fund_required", scale = 6, precision = 19, nullable = false) + private BigDecimal fundRequired; + + @Column(name = "required_total", scale = 6, precision = 19, nullable = false) + private BigDecimal requiredTotal; + + /** IDR held for the category before this run (the previous run's total). */ + @Column(name = "previous_total", scale = 6, precision = 19, nullable = false) + private BigDecimal previousTotal; + + /** Posted to GL: more provision when positive, a write-back when negative. */ + @Column(name = "delta", scale = 6, precision = 19, nullable = false) + private BigDecimal delta; + + @Column(name = "gl_transaction_id", length = 50) + private String glTransactionId; + + @Column(name = "voucher_no", length = 30) + private String voucherNo; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLine.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLine.java new file mode 100644 index 000000000..d77b989e8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLine.java @@ -0,0 +1,75 @@ +/** + * 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.infrastructure.core.domain.AbstractPersistableCustom; + +/** One security of a market valuation: book value still held against the market value at the Daily Script Master rate. */ +@Entity +@Table(name = "m_investment_valuation_line") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentValuationLine extends AbstractPersistableCustom { + + @ManyToOne + @JoinColumn(name = "valuation_id", nullable = false) + private InvestmentValuation valuation; + + @ManyToOne + @JoinColumn(name = "purchase_id", nullable = false) + private InvestmentPurchase purchase; + + /** The security on the valuation date (a later GOI transfer does not change it). */ + @ManyToOne + @JoinColumn(name = "script_id", nullable = false) + private InvestmentScript script; + + @Column(name = "asset_class", nullable = false, length = 20) + private String assetClass; + + @Column(name = "units", scale = 6, precision = 19, nullable = false) + private BigDecimal units; + + @Column(name = "book_value", scale = 6, precision = 19, nullable = false) + private BigDecimal bookValue; + + @Column(name = "rate", scale = 6, precision = 19, nullable = false) + private BigDecimal rate; + + @Column(name = "rate_date", nullable = false) + private LocalDate rateDate; + + @Column(name = "market_value", scale = 6, precision = 19, nullable = false) + private BigDecimal marketValue; + + /** Market value less book value: a loss when negative. */ + @Column(name = "difference", scale = 6, precision = 19, nullable = false) + private BigDecimal difference; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLineRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLineRepository.java new file mode 100644 index 000000000..3a7756084 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationLineRepository.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 InvestmentValuationLineRepository extends JpaRepository { + + List findByValuationIdOrderByIdAsc(Long valuationId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationRepository.java new file mode 100644 index 000000000..8cdba0d80 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentValuationRepository.java @@ -0,0 +1,31 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.domain; + +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InvestmentValuationRepository extends JpaRepository { + + /** The latest run of a category: the IDR it holds is what the next run compares with. */ + Optional findFirstByCategoryOrderByValuationDateDescIdDesc(String category); + + List findAllByOrderByValuationDateDescIdDesc(); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/exception/InvestmentValuationNotFoundException.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/exception/InvestmentValuationNotFoundException.java new file mode 100644 index 000000000..7a9c81989 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/exception/InvestmentValuationNotFoundException.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.exception; + +import org.apache.fineract.infrastructure.core.exception.AbstractPlatformResourceNotFoundException; + +public class InvestmentValuationNotFoundException extends AbstractPlatformResourceNotFoundException { + + public InvestmentValuationNotFoundException(final Long id) { + super("error.msg.investmentvaluation.id.invalid", "Investment valuation " + id + " does not exist", id); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentValuationCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentValuationCommandHandler.java new file mode 100644 index 000000000..ade9f0603 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/CreateInvestmentValuationCommandHandler.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.InvestmentValuationService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTVALUATION", action = "CREATE") +@RequiredArgsConstructor +public class CreateInvestmentValuationCommandHandler implements NewCommandSourceHandler { + + private final InvestmentValuationService valuationService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.valuationService.run(command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SwitchInvestmentFundCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SwitchInvestmentFundCommandHandler.java new file mode 100644 index 000000000..e261a0f58 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/SwitchInvestmentFundCommandHandler.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.InvestmentFundSwitchService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTSALE", action = "SWITCH") +@RequiredArgsConstructor +public class SwitchInvestmentFundCommandHandler implements NewCommandSourceHandler { + + private final InvestmentFundSwitchService switchService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.switchService.switchFund(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDailyRateWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDailyRateWritePlatformServiceImpl.java index 353b1452e..3f1aed489 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDailyRateWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDailyRateWritePlatformServiceImpl.java @@ -32,8 +32,10 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate; import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentScript; import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository; import org.apache.fineract.portfolio.investment.exception.InvestmentDailyRateNotFoundException; @@ -50,6 +52,7 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa private final InvestmentDailyRateCommandFromApiJsonDeserializer validator; private final InvestmentDailyRateRepository dailyRateRepository; private final InvestmentScriptRepository scriptRepository; + private final InvestmentPurchaseRepository purchaseRepository; @Override @Transactional @@ -60,10 +63,11 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa .orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId)); final LocalDate rateDate = command.localDateValueOfParameterNamed(RATE_DATE); // A market rate is observed, so it cannot be dated after today, nor outside the security's life, nor for an - // inactive script. - if (!script.isActive()) { + // inactive script - unless the bank still holds it, as its market valuation needs the rate. + if (!script.isActive() + && !this.purchaseRepository.existsByScriptIdAndStatus(scriptId, InvestmentPurchaseApiConstants.STATUS_ACTIVE)) { throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.script.inactive", - "Script " + script.getScriptCode() + " is inactive"); + "Script " + script.getScriptCode() + " is inactive and no holding of it is left"); } if (rateDate.isAfter(DateUtils.getBusinessLocalDate())) { throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.future.date", "A rate cannot be dated after today"); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java new file mode 100644 index 000000000..bc2f5a967 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java @@ -0,0 +1,270 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.service; + +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_SOLD; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.FROM_NAV; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.LOSS_ON_SALE_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.REMARKS; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_SETTLED; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SUB_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SWITCH_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SWITCH_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TO_NAV; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TO_SCRIPT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_SWITCH; +import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.UNITS; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentSale; +import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentScript; +import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository; +import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException; +import org.apache.fineract.portfolio.investment.exception.InvestmentScriptNotFoundException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Mutual fund switch: units of one scheme are redeemed at its NAV and the proceeds put into another scheme the same day, + * with no money coming to the bank. The units leave the old deal like a sale (cost = their share of the book value still + * held) and a new deal opens for the new scheme at the switch value, AFS, settled at once. One JV dated the switch date: + * Dr new scheme's investment GL (value) / Cr old scheme's investment GL (cost); the gain to 400402 Interest on Bonds or + * the loss to 500605, as on a non-SLR sale (Jitendra Sir). + */ +@Service +@RequiredArgsConstructor +public class InvestmentFundSwitchService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + private static final String MUTUAL_FUND = "MUTUAL_FUND"; + private static final String AFS = "AFS (Available For Sale)"; + + private final FromJsonHelper fromApiJsonHelper; + private final InvestmentPurchaseRepository purchaseRepository; + private final InvestmentSaleRepository saleRepository; + private final InvestmentScriptRepository scriptRepository; + private final InvestmentSaleQuoter quoter; + private final InvestmentNonSlrService nonSlrService; + private final InvestmentJournalPoster journalPoster; + private final JdbcTemplate jdbcTemplate; + + @Transactional + public CommandProcessingResult switchFund(final Long purchaseId, final JsonCommand command) { + validate(command.json()); + final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId) + .orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + final Long toScriptId = command.longValueOfParameterNamed(TO_SCRIPT_ID); + final InvestmentScript to = this.scriptRepository.findById(toScriptId) + .orElseThrow(() -> new InvestmentScriptNotFoundException(toScriptId)); + final LocalDate switchDate = command.localDateValueOfParameterNamed(SWITCH_DATE); + final BigDecimal units = command.bigDecimalValueOfParameterNamed(UNITS); + final BigDecimal fromNav = command.bigDecimalValueOfParameterNamed(FROM_NAV); + final BigDecimal toNav = command.bigDecimalValueOfParameterNamed(TO_NAV); + final String subType = StringUtils.trimToNull(command.stringValueOfParameterNamed(SUB_TYPE)); + + if (!STATUS_ACTIVE.equals(p.getStatus()) || !MUTUAL_FUND.equals(p.getBondType())) { + throw rule("not.active.fund", "Deal " + p.getDealNo() + " is not an active mutual fund holding"); + } + if (switchDate.isAfter(DateUtils.getBusinessLocalDate())) { + throw rule("date.future", "The switch date cannot be after today"); + } + if (switchDate.isBefore(p.getSettlementDate())) { + throw rule("date.before.purchase", + "A switch cannot be dated before the purchase settled on " + p.getSettlementDate().format(DATE)); + } + final BigDecimal available = this.quoter.availableUnits(p, null); + if (units.compareTo(available) > 0) { + throw rule("units.exceed", "Only " + available.stripTrailingZeros().toPlainString() + " units of deal " + p.getDealNo() + + " are available to switch"); + } + if (to.getId().equals(p.getScript().getId())) { + throw rule("same.script", "Choose a different scheme to switch into"); + } + if (!to.isActive()) { + throw rule("script.inactive", "Script " + to.getScriptCode() + " is inactive"); + } + if (!MUTUAL_FUND.equals(to.getBondType())) { + throw rule("script.not.fund", + "Script " + to.getScriptCode() + " is a " + to.getBondType() + "; a switch is into another mutual fund"); + } + // The new scheme must be one the bank may buy: only debt and money-market funds. + this.nonSlrService.requireEligible(to, subType); + + // The units leave at the old scheme's NAV against their share of the book value still held, as on a sale. + final BigDecimal value = units.multiply(fromNav).setScale(2, RoundingMode.HALF_UP); + final boolean allRemaining = units.compareTo(p.getBalanceUnits()) == 0; + final BigDecimal book = this.quoter.remainingBook(p); + final BigDecimal cost = allRemaining ? book : book.multiply(units).divide(p.getBalanceUnits(), 2, RoundingMode.HALF_UP); + final BigDecimal face = p.getUnits().signum() == 0 ? BigDecimal.ZERO + : p.getFaceValue().multiply(units).divide(p.getUnits(), 2, RoundingMode.HALF_UP); + final BigDecimal profitLoss = value.subtract(cost); + final BigDecimal newUnits = value.divide(toNav, 3, RoundingMode.HALF_UP); + + final InvestmentPurchase n = new InvestmentPurchase(); + n.setDealNo(InvestmentPurchaseWritePlatformServiceImpl.nextDealNo(this.jdbcTemplate, switchDate)); + n.setCertNo(InvestmentPurchaseWritePlatformServiceImpl.nextCertNo(this.jdbcTemplate)); + + final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines().debit(to.getGlAccount().getId(), value) + .credit(p.getInvestmentGlAccount().getId(), cost); + if (profitLoss.signum() > 0) { + lines.credit(this.journalPoster.glId(InvestmentInterestApiConstants.INTEREST_INCOME_BONDS_GL_CODE), profitLoss); + } else if (profitLoss.signum() < 0) { + lines.debit(this.journalPoster.glId(LOSS_ON_SALE_GL_CODE), profitLoss.negate()); + } + final CommandProcessingResult posted = this.journalPoster.post(switchDate, p.getDealNo(), + "Mutual fund switch: deal " + p.getDealNo() + ", " + units.stripTrailingZeros().toPlainString() + " units of " + + p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc() + " @ NAV " + fromNav.toPlainString() + + " into " + to.getScriptCode() + " " + to.getScriptDesc() + " @ NAV " + toNav.toPlainString() + " (deal " + + n.getDealNo() + ")", + lines); + final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo"); + final String remarks = StringUtils.trimToNull(command.stringValueOfParameterNamed(REMARKS)); + + final InvestmentSale s = new InvestmentSale(); + s.setPurchase(p); + s.setSaleType(TYPE_SWITCH); + s.setParty(p.getParty()); + s.setDealDate(switchDate); + s.setSettlementType("T+0"); + s.setSettlementDate(switchDate); + s.setUnits(units); + s.setFaceValue(face); + s.setSaleRate(fromNav); + s.setConsideration(value); + s.setAccruedDays(0); + s.setAccruedInterest(BigDecimal.ZERO); + s.setReceivableCleared(BigDecimal.ZERO); + s.setBrokerage(BigDecimal.ZERO); + s.setBookValueSold(cost); + s.setPremiumCleared(BigDecimal.ZERO); + s.setProfitLoss(profitLoss); + // Nothing comes to the bank; the bank GL column keeps the deal's own bank, as on a write-off. + s.setNetAmount(BigDecimal.ZERO); + s.setBankGlAccount(p.getCrGlAccount()); + s.setRemarks(StringUtils.left("Switched to deal " + n.getDealNo() + " " + to.getScriptCode() + " " + to.getScriptDesc() + + (remarks == null ? "" : " - " + remarks), 500)); + s.setStatus(STATUS_SETTLED); + s.setGlTransactionId(posted.getTransactionId()); + s.setVoucherNo(voucherNo == null ? null : voucherNo.toString()); + s.setSettledOnDate(DateUtils.getBusinessLocalDate()); + final InvestmentSale saved = this.saleRepository.saveAndFlush(s); + p.setBalanceUnits(p.getBalanceUnits().subtract(units)); + if (p.getBalanceUnits().signum() == 0) { + p.setStatus(STATUS_SOLD); + } + this.purchaseRepository.saveAndFlush(p); + + n.setBondType(MUTUAL_FUND); + n.setScript(to); + n.setParty(p.getParty()); + n.setDealDate(switchDate); + n.setSettlementType("T+0"); + n.setSettlementDate(switchDate); + n.setCategory(AFS); + n.setSubType(subType); + n.setIntRate(to.getIntRate()); + n.setIssueDate(to.getIssueDate()); + n.setMaturityDate(to.getMaturityDate()); + n.setInvestmentGlAccount(to.getGlAccount()); + n.setCrGlAccount(p.getCrGlAccount()); + final JsonObject other = new JsonObject(); + other.addProperty("switchedFromDeal", p.getDealNo()); + if (remarks != null) { + other.addProperty("remarks", remarks); + } + n.setOtherDetails(other.toString()); + n.setFaceValue(value); + n.setPurchaseRate(toNav); + n.setUnits(newUnits); + n.setBalanceUnits(newUnits); + n.setBookValue(value); + n.setAccruedInterest(BigDecimal.ZERO); + n.setAccruedDays(0); + n.setBrokerage(BigDecimal.ZERO); + n.setStampDuty(BigDecimal.ZERO); + n.setOtherCharges(BigDecimal.ZERO); + n.setSettlementAmount(value); + n.setStatus(STATUS_ACTIVE); + n.setGlTransactionId(posted.getTransactionId()); + n.setSettledOnDate(DateUtils.getBusinessLocalDate()); + final InvestmentPurchase opened = this.purchaseRepository.saveAndFlush(n); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(opened.getId()) + .withSubEntityId(saved.getId()).withResourceIdAsString(opened.getDealNo()).withTransactionId(posted.getTransactionId()) + .build(); + } + + private void validate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SWITCH_PARAMS); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource("investmentsale"); + v.reset().parameter(SWITCH_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(SWITCH_DATE, element)).notNull(); + v.reset().parameter(UNITS).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(UNITS, element)).notNull() + .positiveAmount(); + v.reset().parameter(FROM_NAV).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(FROM_NAV, element)).notNull() + .positiveAmount(); + v.reset().parameter(TO_SCRIPT_ID).value(this.fromApiJsonHelper.extractLongNamed(TO_SCRIPT_ID, element)).notNull() + .longGreaterThanZero(); + v.reset().parameter(TO_NAV).value(this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(TO_NAV, element)).notNull() + .positiveAmount(); + v.reset().parameter(SUB_TYPE).value(this.fromApiJsonHelper.extractStringNamed(SUB_TYPE, element)).notBlank() + .notExceedingLengthOf(40); + v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).ignoreIfNull() + .notExceedingLengthOf(400); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static GeneralPlatformDomainRuleException rule(final String code, final String message) { + return new GeneralPlatformDomainRuleException("error.msg.investmentsale.switch." + code, message); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index 7866cecdf..f69ba0225 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -154,8 +154,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur } final InvestmentPurchase p = new InvestmentPurchase(); - p.setDealNo(nextDealNo(dealDate)); - p.setCertNo(nextCertNo()); + p.setDealNo(nextDealNo(this.jdbcTemplate, dealDate)); + p.setCertNo(nextCertNo(this.jdbcTemplate)); p.setBondType(bondType); p.setScript(script); p.setParty(party); @@ -362,10 +362,13 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP); } - /** Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. */ - private String nextDealNo(final LocalDate dealDate) { + /** + * Financial-year deal number: FY start year + FY end (2 digits) + 6-digit running number, e.g. 202627000001. Also used + * by a mutual fund switch, which opens a new deal. + */ + static String nextDealNo(final JdbcTemplate jdbcTemplate, final LocalDate dealDate) { final String prefix = dealNoPrefix(dealDate); - final Integer max = this.jdbcTemplate.queryForObject( + final Integer max = jdbcTemplate.queryForObject( "SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class, prefix + "%"); return prefix + String.format("%06d", (max == null ? 0 : max) + 1); @@ -377,8 +380,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur return fyStart + String.format("%02d", (fyStart + 1) % 100); } - private String nextCertNo() { - final Integer max = this.jdbcTemplate.queryForObject( + static String nextCertNo(final JdbcTemplate jdbcTemplate) { + final Integer max = jdbcTemplate.queryForObject( "SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class); return String.valueOf((max == null ? 0 : max) + 1); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java new file mode 100644 index 000000000..2e98e314f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentValuationService.java @@ -0,0 +1,346 @@ +/** + * 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.InvestmentValuationApiConstants.CATEGORIES; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CATEGORY; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_BOND; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_FUND; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.CLASS_GOVT; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_PROVISION_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.DEPRECIATION_RESERVE_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.RUN_PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentValuationApiConstants.VALUATION_DATE; + +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; +import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder; +import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.exception.InvalidJsonException; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; +import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants; +import org.apache.fineract.portfolio.investment.data.InvestmentValuationData; +import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRate; +import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentSale; +import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentScript; +import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransfer; +import org.apache.fineract.portfolio.investment.domain.InvestmentSecurityTransferRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentValuation; +import org.apache.fineract.portfolio.investment.domain.InvestmentValuationLine; +import org.apache.fineract.portfolio.investment.domain.InvestmentValuationLineRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentValuationRepository; +import org.apache.fineract.portfolio.investment.exception.InvestmentValuationNotFoundException; +import org.springframework.data.domain.Sort; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Market valuation (MTM) of AFS and HFT holdings (RBI: HFT monthly, AFS quarterly, or more often; HTM is carried at cost). + * Each security still held on the valuation date is valued at the latest Daily Script Master rate on or before that date + * — a bond or Govt security at units x unit value x rate / 100, a mutual fund at units x NAV — against the book value + * still held. Gains and losses are netted within each class (Govt securities, corporate bonds, mutual funds); a net loss + * is the IDR the class needs, a net gain is ignored, and one class never offsets another. The book value does not + * change: the run posts only the difference from the IDR the category's previous run held — Dr 500504 Provision for + * Depreciation / Cr 200205 Investment Depreciation Reserve when it grows, the reverse (a write-back) when it falls. + */ +@Service +@RequiredArgsConstructor +public class InvestmentValuationService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + private static final BigDecimal HUNDRED = BigDecimal.valueOf(100); + private static final List VALUED_BOND_TYPES = List.of("GOVT_SECURITY", "NON_SLR", "BOND", "MUTUAL_FUND"); + + private final FromJsonHelper fromApiJsonHelper; + private final InvestmentPurchaseRepository purchaseRepository; + private final InvestmentSaleRepository saleRepository; + private final InvestmentSecurityTransferRepository transferRepository; + private final InvestmentDailyRateRepository dailyRateRepository; + private final InvestmentValuationRepository valuationRepository; + private final InvestmentValuationLineRepository lineRepository; + private final InvestmentJournalPoster journalPoster; + + /** Securities valued on a date, with the scripts that have no rate yet. */ + private record Valued(List lines, List missingRates) { + } + + /** What a run on {@code valuationDate} would find and post, without saving anything. */ + @Transactional(readOnly = true) + public InvestmentValuationData preview(final LocalDate valuationDate, final String category) { + requireCategory(category); + final Valued valued = value(valuationDate, category); + final Map required = required(valued.lines()); + final Optional last = this.valuationRepository.findFirstByCategoryOrderByValuationDateDescIdDesc(category); + final BigDecimal previous = last.map(InvestmentValuation::getRequiredTotal).orElse(BigDecimal.ZERO); + final BigDecimal total = total(required); + final boolean afterLast = last.isEmpty() || valuationDate.isAfter(last.get().getValuationDate()); + final boolean postable = afterLast && valued.missingRates().isEmpty() && !valuationDate.isAfter(DateUtils.getBusinessLocalDate()) + && (!valued.lines().isEmpty() || previous.signum() > 0); + return InvestmentValuationData.builder().valuationDate(valuationDate.format(DATE)).category(category).requiredTotal(total) + .previousTotal(previous).delta(total.subtract(previous)).provisionGlCode(DEPRECIATION_PROVISION_GL_CODE) + .reserveGlCode(DEPRECIATION_RESERVE_GL_CODE).lastRunDate(last.map(x -> x.getValuationDate().format(DATE)).orElse(null)) + .missingRates(valued.missingRates()).postable(postable).classes(classes(valued.lines(), required)) + .lines(valued.lines().stream().map(InvestmentValuationService::line).toList()).build(); + } + + /** Values the category's holdings on the date and posts the change in IDR, dated the valuation date. */ + @Transactional + public CommandProcessingResult run(final JsonCommand command) { + validate(command.json()); + final LocalDate valuationDate = command.localDateValueOfParameterNamed(VALUATION_DATE); + final String category = command.stringValueOfParameterNamed(CATEGORY); + requireCategory(category); + if (valuationDate.isAfter(DateUtils.getBusinessLocalDate())) { + throw rule("future", "A valuation cannot be dated after today"); + } + final Optional last = this.valuationRepository.findFirstByCategoryOrderByValuationDateDescIdDesc(category); + if (last.isPresent() && !valuationDate.isAfter(last.get().getValuationDate())) { + throw rule("not.after.last", "The last " + category + " valuation is dated " + last.get().getValuationDate().format(DATE) + + "; a new one must be dated after it"); + } + final Valued valued = value(valuationDate, category); + if (!valued.missingRates().isEmpty()) { + throw rule("rate.missing", "Enter the market rate / NAV on or before " + valuationDate.format(DATE) + + " in Daily Script Master for: " + String.join(", ", valued.missingRates())); + } + final BigDecimal previous = last.map(InvestmentValuation::getRequiredTotal).orElse(BigDecimal.ZERO); + if (valued.lines().isEmpty() && previous.signum() == 0) { + throw rule("nothing", "No " + category + " holding to value on " + valuationDate.format(DATE)); + } + final Map required = required(valued.lines()); + final BigDecimal total = total(required); + final BigDecimal delta = total.subtract(previous); + + final InvestmentValuation v = new InvestmentValuation(); + v.setValuationDate(valuationDate); + v.setCategory(category); + v.setGovtRequired(required.get(CLASS_GOVT)); + v.setBondRequired(required.get(CLASS_BOND)); + v.setFundRequired(required.get(CLASS_FUND)); + v.setRequiredTotal(total); + v.setPreviousTotal(previous); + v.setDelta(delta); + String transactionId = null; + if (delta.signum() != 0) { + final Long provisionGl = this.journalPoster.glId(DEPRECIATION_PROVISION_GL_CODE); + final Long reserveGl = this.journalPoster.glId(DEPRECIATION_RESERVE_GL_CODE); + final BigDecimal amount = delta.abs(); + final InvestmentJournalPoster.Lines lines = delta.signum() > 0 + ? new InvestmentJournalPoster.Lines().debit(provisionGl, amount).credit(reserveGl, amount) + : new InvestmentJournalPoster.Lines().debit(reserveGl, amount).credit(provisionGl, amount); + final String reference = "MTM " + category + " " + valuationDate.format(DATE); + final CommandProcessingResult posted = this.journalPoster.post(valuationDate, reference, + "Market valuation (MTM) of " + category + " investments as on " + valuationDate.format(DATE) + ": depreciation reserve " + + "needed " + total.toPlainString() + ", held " + previous.toPlainString() + + (delta.signum() > 0 ? " (provision)" : " (write-back)"), + lines); + transactionId = posted.getTransactionId(); + final Object voucherNo = posted.getChanges() == null ? null : posted.getChanges().get("voucherNo"); + v.setGlTransactionId(transactionId); + v.setVoucherNo(voucherNo == null ? null : voucherNo.toString()); + } + final InvestmentValuation saved = this.valuationRepository.saveAndFlush(v); + for (final InvestmentValuationLine line : valued.lines()) { + line.setValuation(saved); + } + this.lineRepository.saveAllAndFlush(valued.lines()); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) + .withResourceIdAsString(saved.getVoucherNo() == null ? valuationDate.format(DATE) : saved.getVoucherNo()) + .withTransactionId(transactionId).build(); + } + + /** Runs, newest first, without their lines. */ + @Transactional(readOnly = true) + public List retrieveAll() { + return this.valuationRepository.findAllByOrderByValuationDateDescIdDesc().stream().map(v -> data(v, List.of())).toList(); + } + + @Transactional(readOnly = true) + public InvestmentValuationData retrieveOne(final Long valuationId) { + final InvestmentValuation v = this.valuationRepository.findById(valuationId) + .orElseThrow(() -> new InvestmentValuationNotFoundException(valuationId)); + return data(v, this.lineRepository.findByValuationIdOrderByIdAsc(valuationId)); + } + + /** + * Every holding of the category settled on or before the date with units still held then: units sold after the date + * are added back, and the book value is cost less what sales, write-offs and amortisation settled by then took off. + */ + private Valued value(final LocalDate valuationDate, final String category) { + final List lines = new ArrayList<>(); + final List missing = new ArrayList<>(); + for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { + if (!VALUED_BOND_TYPES.contains(p.getBondType()) || p.getCategory() == null || !p.getCategory().startsWith(category) + || InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus()) + || InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus()) + || p.getSettlementDate().isAfter(valuationDate)) { + continue; + } + BigDecimal units = p.getBalanceUnits(); + BigDecimal book = p.getBookValue(); + for (final InvestmentSale s : this.saleRepository.findByPurchaseIdOrderByIdAsc(p.getId())) { + if (!InvestmentSaleApiConstants.STATUS_SETTLED.equals(s.getStatus())) { + continue; + } + if (s.getSettlementDate().isAfter(valuationDate)) { + units = units.add(s.getUnits()); + } else { + book = book.subtract(s.getBookValueSold() == null ? BigDecimal.ZERO : s.getBookValueSold()); + } + } + if (units.signum() <= 0) { + continue; + } + // A holding transferred after the valuation date was still the old security on that date. + final InvestmentScript script = this.transferRepository.findByPurchaseIdOrderByTransferDateAscIdAsc(p.getId()).stream() + .filter(t -> t.getTransferDate().isAfter(valuationDate)).findFirst().map(InvestmentSecurityTransfer::getFromScript) + .orElse(p.getScript()); + final Optional rate = this.dailyRateRepository + .findFirstByScriptIdAndRateDateLessThanEqualOrderByRateDateDesc(script.getId(), valuationDate); + if (rate.isEmpty()) { + final String name = script.getScriptCode() + " " + script.getScriptDesc(); + if (!missing.contains(name)) { + missing.add(name); + } + continue; + } + final BigDecimal close = rate.get().getCloseRate(); + final BigDecimal market = "MUTUAL_FUND".equals(p.getBondType()) ? units.multiply(close).setScale(2, RoundingMode.HALF_UP) + : units.multiply(script.getFaceValuePerUnit()).multiply(close).divide(HUNDRED, 2, RoundingMode.HALF_UP); + final BigDecimal bookHeld = book.setScale(2, RoundingMode.HALF_UP); + + final InvestmentValuationLine line = new InvestmentValuationLine(); + line.setPurchase(p); + line.setScript(script); + line.setAssetClass(assetClass(p.getBondType())); + line.setUnits(units); + line.setBookValue(bookHeld); + line.setRate(close); + line.setRateDate(rate.get().getRateDate()); + line.setMarketValue(market); + line.setDifference(market.subtract(bookHeld)); + lines.add(line); + } + return new Valued(lines, missing); + } + + private static void requireCategory(final String category) { + if (!CATEGORIES.contains(category)) { + throw rule("category.invalid", "Choose AFS or HFT; HTM holdings are carried at cost and not marked to market"); + } + } + + private static String assetClass(final String bondType) { + if ("GOVT_SECURITY".equals(bondType)) { + return CLASS_GOVT; + } + return "MUTUAL_FUND".equals(bondType) ? CLASS_FUND : CLASS_BOND; + } + + /** IDR each class needs: its net loss; a net gain needs none. */ + private static Map required(final List lines) { + final Map required = new LinkedHashMap<>(); + for (final String c : List.of(CLASS_GOVT, CLASS_BOND, CLASS_FUND)) { + final BigDecimal net = lines.stream().filter(l -> c.equals(l.getAssetClass())).map(InvestmentValuationLine::getDifference) + .reduce(BigDecimal.ZERO, BigDecimal::add); + required.put(c, net.signum() < 0 ? net.negate() : BigDecimal.ZERO); + } + return required; + } + + private static BigDecimal total(final Map required) { + return required.values().stream().reduce(BigDecimal.ZERO, BigDecimal::add); + } + + private static List classes(final List lines, + final Map required) { + final List classes = new ArrayList<>(); + for (final Map.Entry e : required.entrySet()) { + final List of = lines.stream().filter(l -> e.getKey().equals(l.getAssetClass())).toList(); + final BigDecimal book = of.stream().map(InvestmentValuationLine::getBookValue).reduce(BigDecimal.ZERO, BigDecimal::add); + final BigDecimal market = of.stream().map(InvestmentValuationLine::getMarketValue).reduce(BigDecimal.ZERO, BigDecimal::add); + classes.add(InvestmentValuationData.ClassTotal.builder().assetClass(e.getKey()).bookValue(book).marketValue(market) + .netDifference(market.subtract(book)).required(e.getValue()).build()); + } + return classes; + } + + private static InvestmentValuationData data(final InvestmentValuation v, final List lines) { + final Map required = new LinkedHashMap<>(); + required.put(CLASS_GOVT, v.getGovtRequired()); + required.put(CLASS_BOND, v.getBondRequired()); + required.put(CLASS_FUND, v.getFundRequired()); + return InvestmentValuationData.builder().id(v.getId()).valuationDate(v.getValuationDate().format(DATE)).category(v.getCategory()) + .requiredTotal(v.getRequiredTotal()).previousTotal(v.getPreviousTotal()).delta(v.getDelta()) + .provisionGlCode(DEPRECIATION_PROVISION_GL_CODE).reserveGlCode(DEPRECIATION_RESERVE_GL_CODE) + .glTransactionId(v.getGlTransactionId()).voucherNo(v.getVoucherNo()).missingRates(List.of()).postable(false) + .classes(classes(lines, required)).lines(lines.stream().map(InvestmentValuationService::line).toList()).build(); + } + + private static InvestmentValuationData.Line line(final InvestmentValuationLine l) { + final InvestmentPurchase p = l.getPurchase(); + return InvestmentValuationData.Line.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()) + .bondType(p.getBondType()).scriptCode(l.getScript().getScriptCode()).scriptDesc(l.getScript().getScriptDesc()) + .assetClass(l.getAssetClass()).units(l.getUnits()).bookValue(l.getBookValue()).rate(l.getRate()) + .rateDate(l.getRateDate().format(DATE)).marketValue(l.getMarketValue()).difference(l.getDifference()).build(); + } + + private void validate(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, RUN_PARAMS); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource("investmentvaluation"); + v.reset().parameter(VALUATION_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(VALUATION_DATE, element)).notNull(); + v.reset().parameter(CATEGORY).value(this.fromApiJsonHelper.extractStringNamed(CATEGORY, element)).notBlank(); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static GeneralPlatformDomainRuleException rule(final String code, final String message) { + return new GeneralPlatformDomainRuleException("error.msg.investmentvaluation." + code, message); + } +} 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 dddf44693..3a78a8364 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 @@ -201,4 +201,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0610_investment_valuation_and_fund_switch.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0610_investment_valuation_and_fund_switch.xml new file mode 100644 index 000000000..b7b6f6b1b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0610_investment_valuation_and_fund_switch.xml @@ -0,0 +1,114 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE entity_name = 'INVESTMENTVALUATION' + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES + ('portfolio', 'READ_INVESTMENTVALUATION', 'INVESTMENTVALUATION', 'READ', FALSE), + ('portfolio', 'CREATE_INVESTMENTVALUATION', 'INVESTMENTVALUATION', 'CREATE', FALSE), + ('portfolio', 'CREATE_INVESTMENTVALUATION_CHECKER', 'INVESTMENTVALUATION', 'CREATE_CHECKER', FALSE) + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code = 'SWITCH_INVESTMENTSALE' + + + INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker) VALUES + ('portfolio', 'SWITCH_INVESTMENTSALE', 'INVESTMENTSALE', 'SWITCH', FALSE), + ('portfolio', 'SWITCH_INVESTMENTSALE_CHECKER', 'INVESTMENTSALE', 'SWITCH_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_INVESTMENTVALUATION', 'CREATE_INVESTMENTVALUATION', 'SWITCH_INVESTMENTSALE') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + +