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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 15:03:38 +05:30
co-authored by Claude Opus 5.5
parent 9bd1e5306f
commit 8db4fc7fee
20 changed files with 1332 additions and 10 deletions
@@ -255,6 +255,22 @@ public class CommandWrapperBuilder {
return this; 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) { public CommandWrapperBuilder transferInvestmentSecurity(final Long purchaseId) {
this.actionName = "CREATE"; this.actionName = "CREATE";
this.entityName = "INVESTMENTTRANSFER"; this.entityName = "INVESTMENTTRANSFER";
@@ -189,6 +189,18 @@ public class InvestmentPurchaseApiResource {
return this.commandsSourceWritePlatformService.logCommandSource(request); 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 @DELETE
@Path("{purchaseId}") @Path("{purchaseId}")
@Produces({ MediaType.APPLICATION_JSON }) @Produces({ MediaType.APPLICATION_JSON })
@@ -34,6 +34,8 @@ public final class InvestmentSaleApiConstants {
public static final String TYPE_WRITEOFF = "WRITEOFF"; 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. */ /** 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 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_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
public static final String STATUS_SETTLED = "SETTLED"; 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 REMARKS = "remarks";
public static final String AMOUNT = "amount"; public static final String AMOUNT = "amount";
public static final String FY_END = "fyEnd"; 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 LOCALE = "locale";
public static final String DATE_FORMAT = "dateFormat"; 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> 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> 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> 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);
} }
@@ -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 &amp; 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);
}
@@ -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);
}
}
@@ -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;
}
}
@@ -33,4 +33,6 @@ public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPu
Optional<InvestmentPurchase> findByIdWithLock(@Param("purchaseId") Long purchaseId); Optional<InvestmentPurchase> findByIdWithLock(@Param("purchaseId") Long purchaseId);
boolean existsByScriptIdAndStatusNot(Long scriptId, String status); boolean existsByScriptIdAndStatusNot(Long scriptId, String status);
boolean existsByScriptIdAndStatus(Long scriptId, String status);
} }
@@ -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;
}
@@ -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;
}
@@ -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);
}
@@ -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();
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils; 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.InvestmentDailyRate;
import org.apache.fineract.portfolio.investment.domain.InvestmentDailyRateRepository; 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.InvestmentScript;
import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentScriptRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentDailyRateNotFoundException; import org.apache.fineract.portfolio.investment.exception.InvestmentDailyRateNotFoundException;
@@ -50,6 +52,7 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa
private final InvestmentDailyRateCommandFromApiJsonDeserializer validator; private final InvestmentDailyRateCommandFromApiJsonDeserializer validator;
private final InvestmentDailyRateRepository dailyRateRepository; private final InvestmentDailyRateRepository dailyRateRepository;
private final InvestmentScriptRepository scriptRepository; private final InvestmentScriptRepository scriptRepository;
private final InvestmentPurchaseRepository purchaseRepository;
@Override @Override
@Transactional @Transactional
@@ -60,10 +63,11 @@ public class InvestmentDailyRateWritePlatformServiceImpl implements InvestmentDa
.orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId)); .orElseThrow(() -> new InvestmentScriptNotFoundException(scriptId));
final LocalDate rateDate = command.localDateValueOfParameterNamed(RATE_DATE); 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 // A market rate is observed, so it cannot be dated after today, nor outside the security's life, nor for an
// inactive script. // inactive script - unless the bank still holds it, as its market valuation needs the rate.
if (!script.isActive()) { if (!script.isActive()
&& !this.purchaseRepository.existsByScriptIdAndStatus(scriptId, InvestmentPurchaseApiConstants.STATUS_ACTIVE)) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.script.inactive", 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())) { if (rateDate.isAfter(DateUtils.getBusinessLocalDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.future.date", "A rate cannot be dated after today"); throw new GeneralPlatformDomainRuleException("error.msg.investmentdailyrate.future.date", "A rate cannot be dated after today");
@@ -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);
}
}
@@ -154,8 +154,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
} }
final InvestmentPurchase p = new InvestmentPurchase(); final InvestmentPurchase p = new InvestmentPurchase();
p.setDealNo(nextDealNo(dealDate)); p.setDealNo(nextDealNo(this.jdbcTemplate, dealDate));
p.setCertNo(nextCertNo()); p.setCertNo(nextCertNo(this.jdbcTemplate));
p.setBondType(bondType); p.setBondType(bondType);
p.setScript(script); p.setScript(script);
p.setParty(party); p.setParty(party);
@@ -362,10 +362,13 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return BigDecimal.valueOf(ytm).setScale(2, RoundingMode.HALF_UP); 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 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, "SELECT MAX(CAST(SUBSTRING(deal_no FROM 7) AS INTEGER)) FROM m_investment_purchase WHERE deal_no LIKE ?", Integer.class,
prefix + "%"); prefix + "%");
return prefix + String.format("%06d", (max == null ? 0 : max) + 1); 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); return fyStart + String.format("%02d", (fyStart + 1) % 100);
} }
private String nextCertNo() { static String nextCertNo(final JdbcTemplate jdbcTemplate) {
final Integer max = this.jdbcTemplate.queryForObject( final Integer max = jdbcTemplate.queryForObject(
"SELECT MAX(CAST(cert_no AS INTEGER)) FROM m_investment_purchase WHERE cert_no ~ '^[0-9]+$'", Integer.class); "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); return String.valueOf((max == null ? 0 : max) + 1);
} }
@@ -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/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/0608_apply_rd_base_chart_to_rd_products.xml" relativeToChangelogFile="true"/>
<include file="parts/0609_investment_script_rating.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> </databaseChangeLog>
@@ -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>