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