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