feat: add portfolio investment api constants and domain models
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 4 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 3 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 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

This commit is contained in:
2026-10-29 13:55:36 +05:30
parent d5f188258f
commit 33e7aef618
20 changed files with 705 additions and 11 deletions
@@ -86,6 +86,15 @@ public final class InvestmentApiConstants {
public static final String PARTY_CODE_PREFIX = "PTY-";
/**
* Deal slip: how the deal was confirmed to the counterparty (entered with the deal), and the counterparty's
* confirmation the back office records when it settles the deal.
*/
public static final Set<String> CONFIRMATION_MODES = Set.of("EMAIL", "NDS_OM", "FAX", "LETTER", "PHONE");
public static final String CONFIRMATION_MODE = "confirmationMode";
public static final String CONFIRMATION_RECEIVED_ON = "confirmationReceivedOn";
public static final String CONFIRMATION_REF = "confirmationRef";
// Common parameters
public static final String BOND_TYPE = "bondType";
public static final String GL_ACCOUNT_ID = "glAccountId";
@@ -0,0 +1,72 @@
/**
* 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.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.MediaType;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.investment.data.InvestmentDealSlipData;
import org.apache.fineract.portfolio.investment.service.InvestmentDealSlipService;
import org.springframework.stereotype.Component;
@Path("/v1/investment/deal-slips")
@Component
@Tag(name = "Investment Deal Slips", description = "Serially numbered deal slips of every purchase, sale, refund, redemption and "
+ "switch, cancelled ones included, with the confirmation and who entered, approved and settled each deal.")
@RequiredArgsConstructor
public class InvestmentDealSlipApiResource {
/** Default window when no dates are given: the last 30 days. */
private static final int DEFAULT_DAYS = 30;
private final PlatformSecurityContext context;
private final InvestmentDealSlipService dealSlipService;
@GET
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Deal slip register", description = "Deals dated fromDate to toDate (dd-MM-yyyy; default the last 30 days) "
+ "in slip number order; or the one slip numbered slipNo (a purchase deal number or a sale number).")
public List<InvestmentDealSlipData> retrieve(@QueryParam("fromDate") @Parameter(description = "dd-MM-yyyy") final String fromDate,
@QueryParam("toDate") @Parameter(description = "dd-MM-yyyy") final String toDate,
@QueryParam("slipNo") @Parameter(description = "202627000001 or 202627S00001") final String slipNo) {
this.context.authenticatedUser().validateHasReadPermission(InvestmentPurchaseApiConstants.ENTITY);
if (StringUtils.isNotBlank(slipNo)) {
return this.dealSlipService.slip(slipNo);
}
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate to = InvestmentApiDates.parse(toDate, today, "To date");
final LocalDate from = InvestmentApiDates.parse(fromDate, to.minusDays(DEFAULT_DAYS), "From date");
if (to.isBefore(from)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.dealslip.dates", "The To date cannot be before the From date");
}
return this.dealSlipService.register(from, to);
}
}
@@ -72,7 +72,10 @@ public final class InvestmentPurchaseApiConstants {
public static final Set<String> PARAMS = Set.of(BOND_TYPE, SCRIPT_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, FACE_VALUE,
PURCHASE_RATE, CONSIDER_AMT, NAV, STAMP_DUTY, BROKERAGE, OTHER_CHARGES, CATEGORY, LAST_INT_DATE, CR_GL_ACCOUNT_ID, SUB_TYPE,
OTHER_DETAILS, LOCALE, DATE_FORMAT);
OTHER_DETAILS, InvestmentApiConstants.CONFIRMATION_MODE, LOCALE, DATE_FORMAT);
/** Settle may carry the counterparty's confirmation of the deal. */
public static final Set<String> SETTLE_PARAMS = Set.of(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON,
InvestmentApiConstants.CONFIRMATION_REF, LOCALE, DATE_FORMAT);
/** Dividend received on a share holding: Dr Bank / Cr 400404 Dividend on Investments, dated the day it is received. */
public static final String DIVIDEND_GL_CODE = "400404";
@@ -39,6 +39,11 @@ public final class InvestmentSaleApiConstants {
public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT";
public static final String STATUS_SETTLED = "SETTLED";
/** Dropped before settlement; kept so its sale number stays accounted for in the deal slip register. */
public static final String STATUS_DROPPED = "DROPPED";
/** Sales that are deals with a deal slip of their own (a sale number); write-offs and amortisation are not. */
public static final Set<String> NUMBERED_TYPES = Set.of(TYPE_SELL, TYPE_REDEEM, TYPE_SWITCH);
/** Profit on sale / redemption (income) and loss on sale / redemption (expense). */
public static final String PROFIT_ON_SALE_GL_CODE = "400405";
@@ -59,6 +64,7 @@ public final class InvestmentSaleApiConstants {
public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId";
public static final String SANCTION_AUTHORITY = "sanctionAuthority";
public static final String REMARKS = "remarks";
public static final String BROKER_CODE = "brokerCode";
public static final String AMOUNT = "amount";
public static final String FY_END = "fyEnd";
public static final String SWITCH_DATE = "switchDate";
@@ -70,7 +76,11 @@ public final class InvestmentSaleApiConstants {
public static final String DATE_FORMAT = "dateFormat";
public static final Set<String> SELL_PARAMS = Set.of(PURCHASE_ID, PARTY_ID, DEAL_DATE, DEAL_TIME, SETTLEMENT_TYPE, UNITS, SALE_RATE,
BROKERAGE, BANK_GL_ACCOUNT_ID, SANCTION_AUTHORITY, REMARKS, LOCALE, DATE_FORMAT);
BROKERAGE, BANK_GL_ACCOUNT_ID, SANCTION_AUTHORITY, REMARKS, BROKER_CODE, InvestmentApiConstants.CONFIRMATION_MODE, LOCALE,
DATE_FORMAT);
/** Settle may carry the counterparty's confirmation of the deal. */
public static final Set<String> SETTLE_PARAMS = Set.of(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON,
InvestmentApiConstants.CONFIRMATION_REF, 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> AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT);
@@ -0,0 +1,94 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
package org.apache.fineract.portfolio.investment.data;
import java.math.BigDecimal;
import lombok.Builder;
import lombok.Getter;
/**
* One deal slip: a purchase (BUY) or a sale, refund, redemption or switch, with everything the slip must show — the
* counterparty, the security, amount, price, deal date and time, settlement date, how the deal was confirmed, direct or
* through a broker (and the brokerage), and who entered, approved, settled or cancelled it. A dropped deal stays on the
* register as CANCELLED so its number is accounted for.
*/
@Getter
@Builder
public class InvestmentDealSlipData {
/** Purchase deal number (202627000001) or sale number (202627S00001). */
private final String slipNo;
/** BUY, SELL, REFUND (shares), REDEEM or SWITCH. */
private final String side;
/** PENDING_SETTLEMENT, SETTLED or CANCELLED. */
private final String status;
/** Status of the purchase / sale record itself (ACTIVE, SOLD, DROPPED, …). */
private final String recordStatus;
private final Long purchaseId;
private final Long saleId;
/** Deal number of the holding (for a sale, the purchase it sells from). */
private final String dealNo;
private final String certNo;
private final String dealDate;
private final String dealTime;
private final String settlementType;
private final String settlementDate;
private final String partyCode;
private final String counterparty;
private final String bondType;
private final String scriptCode;
private final String scriptDesc;
private final String isin;
private final String category;
private final BigDecimal units;
private final BigDecimal faceValue;
/** Price per ₹100 of face value, NAV per unit or price per share. */
private final BigDecimal price;
/** Price of the units without interest or charges. */
private final BigDecimal consideration;
private final BigDecimal accruedInterest;
private final BigDecimal brokerage;
/** Stamp duty and other charges of a purchase. */
private final BigDecimal otherCharges;
/** Money paid out (purchase) or received (sale). */
private final BigDecimal netAmount;
private final String bankGlCode;
private final String bankGlName;
/** DIRECT or BROKER. */
private final String dealMode;
private final String brokerCode;
private final String tradeNo;
private final String orderNo;
private final String confirmationMode;
private final String confirmationReceivedOn;
private final String confirmationRef;
private final String enteredBy;
private final String enteredOn;
private final String approvedBy;
private final String approvedOn;
private final String settledBy;
private final String settledOn;
private final String settleApprovedBy;
private final String settleApprovedOn;
private final String cancelledBy;
private final String cancelledOn;
private final String voucherNo;
private final String glTransactionId;
private final String remarks;
}
@@ -72,6 +72,9 @@ public class InvestmentPurchaseData {
private final String crGlCode;
private final String crGlName;
private final String otherDetails;
private final String confirmationMode;
private final String confirmationReceivedOn;
private final String confirmationRef;
private final String status;
private final String glTransactionId;
private final String settledOnDate;
@@ -90,7 +93,9 @@ public class InvestmentPurchaseData {
.subType(p.getSubType()).investmentGlAccountId(p.getInvestmentGlAccount().getId())
.investmentGlCode(p.getInvestmentGlAccount().getGlCode()).investmentGlName(p.getInvestmentGlAccount().getName())
.crGlAccountId(p.getCrGlAccount().getId()).crGlCode(p.getCrGlAccount().getGlCode()).crGlName(p.getCrGlAccount().getName())
.otherDetails(p.getOtherDetails()).status(p.getStatus()).glTransactionId(p.getGlTransactionId())
.otherDetails(p.getOtherDetails()).confirmationMode(p.getConfirmationMode())
.confirmationReceivedOn(format(p.getConfirmationReceivedOn())).confirmationRef(p.getConfirmationRef())
.status(p.getStatus()).glTransactionId(p.getGlTransactionId())
.settledOnDate(format(p.getSettledOnDate())).build();
}
@@ -35,6 +35,8 @@ public class InvestmentSaleData {
private final Long id;
private final Long purchaseId;
private final String dealNo;
/** Deal slip number of the sale itself, e.g. 202627S00001 (empty for a write-off or amortisation). */
private final String saleNo;
private final String certNo;
private final String bondType;
private final String scriptCode;
@@ -68,13 +70,17 @@ public class InvestmentSaleData {
private final String premiumGlCode;
private final String sanctionAuthority;
private final String remarks;
private final String brokerCode;
private final String confirmationMode;
private final String confirmationReceivedOn;
private final String confirmationRef;
private final String status;
private final String glTransactionId;
private final String voucherNo;
private final String settledOnDate;
public static InvestmentSaleData from(final InvestmentSale s) {
return builder().id(s.getId()).purchaseId(s.getPurchase().getId()).dealNo(s.getPurchase().getDealNo())
return builder().id(s.getId()).purchaseId(s.getPurchase().getId()).dealNo(s.getPurchase().getDealNo()).saleNo(s.getSaleNo())
.certNo(s.getPurchase().getCertNo()).bondType(s.getPurchase().getBondType())
.scriptCode(s.getPurchase().getScript().getScriptCode()).scriptDesc(s.getPurchase().getScript().getScriptDesc())
.saleType(s.getSaleType()).partyId(s.getParty() == null ? null : s.getParty().getId())
@@ -88,7 +94,9 @@ public class InvestmentSaleData {
.bankGlName(s.getBankGlAccount().getName()).investmentGlCode(s.getPurchase().getInvestmentGlAccount().getGlCode())
.premiumGlCode(s.getPurchase().getPremiumGlAccount() != null ? s.getPurchase().getPremiumGlAccount().getGlCode()
: s.getPurchase().getInvestmentGlAccount().getGlCode())
.sanctionAuthority(s.getSanctionAuthority()).remarks(s.getRemarks()).status(s.getStatus())
.sanctionAuthority(s.getSanctionAuthority()).remarks(s.getRemarks()).brokerCode(s.getBrokerCode())
.confirmationMode(s.getConfirmationMode()).confirmationReceivedOn(format(s.getConfirmationReceivedOn()))
.confirmationRef(s.getConfirmationRef()).status(s.getStatus())
.glTransactionId(s.getGlTransactionId()).voucherNo(s.getVoucherNo()).settledOnDate(format(s.getSettledOnDate())).build();
}
@@ -151,6 +151,17 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom<L
@Column(name = "other_details", length = 4000)
private String otherDetails;
/** How the deal was confirmed to the counterparty (EMAIL, NDS_OM, FAX, LETTER, PHONE), entered with the deal. */
@Column(name = "confirmation_mode", length = 10)
private String confirmationMode;
/** Date the counterparty's confirmation / contract note reached the back office, recorded on Settle. */
@Column(name = "confirmation_received_on")
private LocalDate confirmationReceivedOn;
@Column(name = "confirmation_ref", length = 100)
private String confirmationRef;
@Column(name = "status", nullable = false, length = 20)
private String status;
@@ -19,6 +19,8 @@
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.LockModeType;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
@@ -35,4 +37,9 @@ public interface InvestmentPurchaseRepository extends JpaRepository<InvestmentPu
boolean existsByScriptIdAndStatusNot(Long scriptId, String status);
boolean existsByScriptIdAndStatus(Long scriptId, String status);
/** Every deal dated in the period, dropped ones included (the deal slip register). */
List<InvestmentPurchase> findByDealDateBetween(LocalDate from, LocalDate to);
Optional<InvestmentPurchase> findByDealNo(String dealNo);
}
@@ -43,6 +43,13 @@ public class InvestmentSale extends AbstractAuditableWithUTCDateTimeCustom<Long>
@JoinColumn(name = "purchase_id", nullable = false)
private InvestmentPurchase purchase;
/**
* Deal slip number of a sale, redemption or switch: financial year, "S" and a running number, e.g. 202627S00001.
* Empty for a write-off or amortisation, which are not deals.
*/
@Column(name = "sale_no", length = 20, unique = true)
private String saleNo;
/** SELL, REDEEM, WRITEOFF or AMORTISE. */
@Column(name = "sale_type", nullable = false, length = 10)
private String saleType;
@@ -118,6 +125,21 @@ public class InvestmentSale extends AbstractAuditableWithUTCDateTimeCustom<Long>
@Column(name = "remarks", length = 500)
private String remarks;
/** Broker of a sale done through a broker (Broker Master code); empty for a direct deal. */
@Column(name = "broker_code", length = 20)
private String brokerCode;
/** How the deal was confirmed to the counterparty (EMAIL, NDS_OM, FAX, LETTER, PHONE), entered with the sale. */
@Column(name = "confirmation_mode", length = 10)
private String confirmationMode;
/** Date the counterparty's confirmation / contract note reached the back office, recorded on Settle. */
@Column(name = "confirmation_received_on")
private LocalDate confirmationReceivedOn;
@Column(name = "confirmation_ref", length = 100)
private String confirmationRef;
@Column(name = "status", nullable = false, length = 20)
private String status;
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.investment.domain;
import jakarta.persistence.LockModeType;
import java.time.LocalDate;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
@@ -34,6 +35,11 @@ public interface InvestmentSaleRepository extends JpaRepository<InvestmentSale,
@Query("select s from InvestmentSale s where s.id = :saleId")
Optional<InvestmentSale> findByIdWithLock(@Param("saleId") Long saleId);
/** Every numbered sale, redemption and switch dated in the period, dropped ones included (the deal slip register). */
List<InvestmentSale> findBySaleNoNotNullAndDealDateBetween(LocalDate from, LocalDate to);
Optional<InvestmentSale> findBySaleNo(String saleNo);
/** The deal of a sale, without loading either, so the deal can be locked first. */
@Query("select s.purchase.id from InvestmentSale s where s.id = :saleId")
Optional<Long> findPurchaseIdBySaleId(@Param("saleId") Long saleId);
@@ -38,6 +38,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SCRIPT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLEMENT_TYPES;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SETTLE_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY;
import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE;
@@ -122,6 +123,11 @@ public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
}
v.reset().parameter(SUB_TYPE).value(this.fromApiJsonHelper.extractStringNamed(SUB_TYPE, element)).ignoreIfNull()
.notExceedingLengthOf(40);
final String confirmationMode = this.fromApiJsonHelper.extractStringNamed(InvestmentApiConstants.CONFIRMATION_MODE, element);
if (StringUtils.isNotBlank(confirmationMode)) {
v.reset().parameter(InvestmentApiConstants.CONFIRMATION_MODE).value(confirmationMode)
.isOneOfTheseValues(InvestmentApiConstants.CONFIRMATION_MODES.toArray());
}
if (this.fromApiJsonHelper.parameterExists(OTHER_DETAILS, element)) {
final JsonElement other = element.getAsJsonObject().get(OTHER_DETAILS);
@@ -137,6 +143,26 @@ public final class InvestmentPurchaseCommandFromApiJsonDeserializer {
}
}
/** Settle may record the counterparty's confirmation of the deal: the date it was received and its reference. */
public void validateForSettle(final String json) {
if (StringUtils.isBlank(json)) {
return;
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SETTLE_PARAMS);
final JsonElement element = this.fromApiJsonHelper.parse(json);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
// A malformed date fails here; the dates it may take are checked against the deal.
this.fromApiJsonHelper.extractLocalDateNamed(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON, element);
v.reset().parameter(InvestmentApiConstants.CONFIRMATION_REF)
.value(this.fromApiJsonHelper.extractStringNamed(InvestmentApiConstants.CONFIRMATION_REF, element)).ignoreIfNull()
.notExceedingLengthOf(100);
if (!errors.isEmpty()) {
throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors);
}
}
private BigDecimal amount(final String param, final JsonElement element) {
return this.fromApiJsonHelper.parameterExists(param, element)
? this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(param, element)
@@ -21,6 +21,7 @@ package org.apache.fineract.portfolio.investment.serialization;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.AMOUNT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BANK_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKER_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PARTY_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.PURCHASE_ID;
@@ -31,6 +32,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiCons
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SANCTION_AUTHORITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SELL_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SETTLE_PARAMS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.UNITS;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.WRITEOFF_PARAMS;
@@ -48,6 +50,7 @@ import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.exception.InvalidJsonException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.springframework.stereotype.Component;
@@ -84,6 +87,29 @@ public final class InvestmentSaleCommandFromApiJsonDeserializer {
.ignoreIfNull().notExceedingLengthOf(100);
v.reset().parameter(REMARKS).value(this.fromApiJsonHelper.extractStringNamed(REMARKS, element)).ignoreIfNull()
.notExceedingLengthOf(500);
v.reset().parameter(BROKER_CODE).value(this.fromApiJsonHelper.extractStringNamed(BROKER_CODE, element)).ignoreIfNull()
.notExceedingLengthOf(20);
final String confirmationMode = this.fromApiJsonHelper.extractStringNamed(InvestmentApiConstants.CONFIRMATION_MODE, element);
if (StringUtils.isNotBlank(confirmationMode)) {
v.reset().parameter(InvestmentApiConstants.CONFIRMATION_MODE).value(confirmationMode)
.isOneOfTheseValues(InvestmentApiConstants.CONFIRMATION_MODES.toArray());
}
throwIfAny(errors);
}
/** Settle may record the counterparty's confirmation of the deal: the date it was received and its reference. */
public void validateForSettle(final String json) {
if (StringUtils.isBlank(json)) {
return;
}
final JsonElement element = parse(json, SETTLE_PARAMS);
final List<ApiParameterError> errors = new ArrayList<>();
final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE);
// A malformed date fails here; the dates it may take are checked against the deal.
this.fromApiJsonHelper.extractLocalDateNamed(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON, element);
v.reset().parameter(InvestmentApiConstants.CONFIRMATION_REF)
.value(this.fromApiJsonHelper.extractStringNamed(InvestmentApiConstants.CONFIRMATION_REF, element)).ignoreIfNull()
.notExceedingLengthOf(100);
throwIfAny(errors);
}
@@ -0,0 +1,254 @@
/**
* 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 com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.portfolio.investment.api.InvestmentApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentDealSlipData;
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.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Deal slips and the deal slip register: every purchase and every sale, refund, redemption and switch in serial order,
* dropped ones included as cancelled, with who entered, approved, settled or cancelled each (from the maker-checker
* record of its commands).
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class InvestmentDealSlipService {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
private static final DateTimeFormatter DATE_TIME = DateTimeFormatter.ofPattern("dd-MM-yyyy HH:mm");
private static final String PURCHASE = InvestmentPurchaseApiConstants.ENTITY;
private static final String SALE = InvestmentSaleApiConstants.ENTITY;
/** Command processed (not awaiting approval, rejected or failed). */
private static final int PROCESSED = 1;
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleRepository saleRepository;
private final JdbcTemplate jdbcTemplate;
/** Who made (and who approved) one command on a deal. */
private record Action(String maker, String madeOn, String checker, String checkedOn) {
}
/** Slips of deals dated from {@code from} to {@code to}, in slip number order. */
public List<InvestmentDealSlipData> register(final LocalDate from, final LocalDate to) {
return slips(this.purchaseRepository.findByDealDateBetween(from, to),
this.saleRepository.findBySaleNoNotNullAndDealDateBetween(from, to));
}
/** One slip by its number: a purchase deal number or a sale number; empty when there is none. */
public List<InvestmentDealSlipData> slip(final String slipNo) {
final String no = slipNo.trim();
return slips(this.purchaseRepository.findByDealNo(no).stream().toList(), this.saleRepository.findBySaleNo(no).stream().toList());
}
private List<InvestmentDealSlipData> slips(final List<InvestmentPurchase> purchases, final List<InvestmentSale> sales) {
final Map<String, Action> actions = actions(purchases.stream().map(InvestmentPurchase::getId).toList(),
sales.stream().map(InvestmentSale::getId).toList());
final List<InvestmentDealSlipData> slips = new ArrayList<>();
purchases.forEach(p -> slips.add(purchaseSlip(p, actions)));
sales.forEach(s -> slips.add(saleSlip(s, actions)));
slips.sort(Comparator.comparing(InvestmentDealSlipData::getSlipNo));
return slips;
}
private static InvestmentDealSlipData purchaseSlip(final InvestmentPurchase p, final Map<String, Action> actions) {
final JsonObject other = otherDetails(p.getOtherDetails());
final String brokerCode = text(other, "brokerCode");
// A purchase opened by a mutual fund switch has no CREATE of its own: the switch made it.
final Action entered = actions.getOrDefault(key(PURCHASE, "CREATE", p.getId()), actions.get(key(SALE, "SWITCH", p.getId())));
final Action settled = actions.getOrDefault(key(PURCHASE, "SETTLE", p.getId()), actions.get(key(SALE, "SWITCH", p.getId())));
final Action cancelled = actions.get(key(PURCHASE, "DELETE", p.getId()));
final String status = InvestmentPurchaseApiConstants.STATUS_DROPPED.equals(p.getStatus()) ? "CANCELLED"
: InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT.equals(p.getStatus()) ? "PENDING_SETTLEMENT" : "SETTLED";
final InvestmentDealSlipData.InvestmentDealSlipDataBuilder b = InvestmentDealSlipData.builder().slipNo(p.getDealNo()).side("BUY")
.status(status).recordStatus(p.getStatus()).purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo())
.dealDate(format(p.getDealDate())).dealTime(p.getDealTime()).settlementType(p.getSettlementType())
.settlementDate(format(p.getSettlementDate())).partyCode(p.getParty().getPartyCode())
.counterparty(p.getParty().getPartyName()).bondType(p.getBondType()).scriptCode(p.getScript().getScriptCode())
.scriptDesc(p.getScript().getScriptDesc()).isin(p.getScript().getIsin()).category(p.getCategory()).units(p.getUnits())
.faceValue(p.getFaceValue()).price(p.getPurchaseRate()).consideration(p.getBookValue())
.accruedInterest(p.getAccruedInterest()).brokerage(p.getBrokerage())
.otherCharges(zero(p.getStampDuty()).add(zero(p.getOtherCharges()))).netAmount(p.getSettlementAmount())
.bankGlCode(p.getCrGlAccount().getGlCode()).bankGlName(p.getCrGlAccount().getName())
.dealMode(brokerCode == null ? "DIRECT" : "BROKER").brokerCode(brokerCode).tradeNo(text(other, "tradeNo"))
.orderNo(text(other, "orderNo")).confirmationMode(p.getConfirmationMode())
.confirmationReceivedOn(format(p.getConfirmationReceivedOn())).confirmationRef(p.getConfirmationRef())
.glTransactionId(p.getGlTransactionId());
return who(b, entered, settled, cancelled).build();
}
private static InvestmentDealSlipData saleSlip(final InvestmentSale s, final Map<String, Action> actions) {
final InvestmentPurchase p = s.getPurchase();
final String type = s.getSaleType();
final Action entered;
final Action settled;
if (InvestmentSaleApiConstants.TYPE_SWITCH.equals(type)) {
entered = actions.get(key(SALE, "SWITCH-OUT", s.getId()));
settled = entered;
} else if (InvestmentSaleApiConstants.TYPE_REDEEM.equals(type)) {
entered = actions.get(key(SALE, "REDEEM", s.getId()));
settled = entered;
} else {
entered = actions.get(key(SALE, "CREATE", s.getId()));
settled = actions.get(key(SALE, "SETTLE", s.getId()));
}
final Action cancelled = actions.get(key(SALE, "DELETE", s.getId()));
final String side = InvestmentSaleApiConstants.TYPE_SELL.equals(type)
? InvestmentApiConstants.SHARE.equals(p.getBondType()) ? "REFUND" : "SELL"
: type;
final String status = InvestmentSaleApiConstants.STATUS_DROPPED.equals(s.getStatus()) ? "CANCELLED" : s.getStatus();
// A redemption is repaid by the issuer, not bought by a counterparty.
final String counterparty = s.getParty() != null ? s.getParty().getPartyName()
: InvestmentSaleApiConstants.TYPE_REDEEM.equals(type) ? "Issuer (redemption at maturity)" : null;
final InvestmentDealSlipData.InvestmentDealSlipDataBuilder b = InvestmentDealSlipData.builder().slipNo(s.getSaleNo()).side(side)
.status(status).recordStatus(s.getStatus()).purchaseId(p.getId()).saleId(s.getId()).dealNo(p.getDealNo())
.certNo(p.getCertNo()).dealDate(format(s.getDealDate())).dealTime(s.getDealTime())
.settlementType(s.getSettlementType()).settlementDate(format(s.getSettlementDate()))
.partyCode(s.getParty() == null ? null : s.getParty().getPartyCode()).counterparty(counterparty)
.bondType(p.getBondType()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc())
.isin(p.getScript().getIsin()).category(p.getCategory()).units(s.getUnits()).faceValue(s.getFaceValue())
.price(s.getSaleRate()).consideration(s.getConsideration()).accruedInterest(s.getAccruedInterest())
.brokerage(s.getBrokerage()).otherCharges(BigDecimal.ZERO).netAmount(s.getNetAmount())
.bankGlCode(s.getBankGlAccount().getGlCode()).bankGlName(s.getBankGlAccount().getName())
.dealMode(StringUtils.isBlank(s.getBrokerCode()) ? "DIRECT" : "BROKER").brokerCode(s.getBrokerCode())
.confirmationMode(s.getConfirmationMode()).confirmationReceivedOn(format(s.getConfirmationReceivedOn()))
.confirmationRef(s.getConfirmationRef()).voucherNo(s.getVoucherNo()).glTransactionId(s.getGlTransactionId())
.remarks(s.getRemarks());
return who(b, entered, settled, cancelled).build();
}
private static InvestmentDealSlipData.InvestmentDealSlipDataBuilder who(final InvestmentDealSlipData.InvestmentDealSlipDataBuilder b,
final Action entered, final Action settled, final Action cancelled) {
if (entered != null) {
b.enteredBy(entered.maker()).enteredOn(entered.madeOn()).approvedBy(entered.checker()).approvedOn(entered.checkedOn());
}
if (settled != null) {
b.settledBy(settled.maker()).settledOn(settled.madeOn()).settleApprovedBy(settled.checker())
.settleApprovedOn(settled.checkedOn());
}
if (cancelled != null) {
b.cancelledBy(cancelled.maker()).cancelledOn(cancelled.madeOn());
}
return b;
}
/**
* The processed commands on these deals, keyed entity|action|id. A switch is recorded against the deal it opens, with
* the switched-out sale as its sub-resource, so it is keyed both ways.
*/
private Map<String, Action> actions(final Collection<Long> purchaseIds, final Collection<Long> saleIds) {
final Map<String, Action> actions = new HashMap<>();
if (purchaseIds.isEmpty() && saleIds.isEmpty()) {
return actions;
}
final String purchases = ids(purchaseIds);
final String sales = ids(saleIds);
final String sql = """
SELECT c.entity_name, c.action_name, c.resource_id, c.subresource_id, c.made_on_date_utc, c.checked_on_date_utc,
mk.username AS maker, ck.username AS checker
FROM m_portfolio_command_source c
LEFT JOIN m_appuser mk ON mk.id = c.maker_id
LEFT JOIN m_appuser ck ON ck.id = c.checker_id
WHERE c.status = ?
AND ((c.entity_name = 'INVESTMENTPURCHASE' AND c.resource_id IN (%1$s))
OR (c.entity_name = 'INVESTMENTSALE' AND (c.resource_id IN (%2$s) OR c.resource_id IN (%1$s)
OR c.subresource_id IN (%2$s))))
ORDER BY c.id
""".formatted(purchases, sales);
this.jdbcTemplate.query(sql, rs -> {
final String entity = rs.getString("entity_name");
final String action = rs.getString("action_name");
final long resourceId = rs.getLong("resource_id");
final long subResourceId = rs.getLong("subresource_id");
final Action a = new Action(rs.getString("maker"), dateTime(rs.getObject("made_on_date_utc", OffsetDateTime.class)),
rs.getString("checker"), dateTime(rs.getObject("checked_on_date_utc", OffsetDateTime.class)));
// Later rows (a retry after a rejection) replace earlier ones.
actions.put(key(entity, action, resourceId), a);
if (SALE.equals(entity) && "SWITCH".equals(action)) {
actions.put(key(SALE, "SWITCH-OUT", subResourceId), a);
}
}, PROCESSED);
return actions;
}
/** Ids for an IN list; never empty, so the SQL stays valid. */
private static String ids(final Collection<Long> ids) {
return ids.isEmpty() ? "-1" : ids.stream().map(String::valueOf).collect(Collectors.joining(","));
}
private static String key(final String entity, final String action, final long id) {
return entity + "|" + action + "|" + id;
}
private static JsonObject otherDetails(final String json) {
if (StringUtils.isBlank(json)) {
return new JsonObject();
}
try {
final JsonElement e = JsonParser.parseString(json);
return e.isJsonObject() ? e.getAsJsonObject() : new JsonObject();
} catch (final RuntimeException e) {
return new JsonObject();
}
}
private static String text(final JsonObject o, final String name) {
final JsonElement e = o.get(name);
return e == null || e.isJsonNull() ? null : StringUtils.trimToNull(e.getAsString());
}
private static String dateTime(final OffsetDateTime at) {
return at == null ? null : at.atZoneSameInstant(DateUtils.getDateTimeZoneOfTenant()).format(DATE_TIME);
}
private static String format(final LocalDate date) {
return date == null ? null : date.format(DATE);
}
private static BigDecimal zero(final BigDecimal v) {
return v == null ? BigDecimal.ZERO : v;
}
}
@@ -166,6 +166,7 @@ public class InvestmentFundSwitchService {
final InvestmentSale s = new InvestmentSale();
s.setPurchase(p);
s.setSaleNo(InvestmentPurchaseWritePlatformServiceImpl.nextSaleNo(this.jdbcTemplate, switchDate));
s.setSaleType(TYPE_SWITCH);
s.setParty(p.getParty());
s.setDealDate(switchDate);
@@ -185,6 +185,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
p.setInvestmentGlAccount(script.getGlAccount());
p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID)));
p.setOtherDetails(otherDetails(command));
p.setConfirmationMode(
StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(InvestmentApiConstants.CONFIRMATION_MODE)));
p.setStatus(STATUS_PENDING_SETTLEMENT);
// Brokerage, stamp duty and other charges are expenses of the deal (Jitendra Sir): paid with the
@@ -250,11 +252,15 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
@Override
@Transactional
public CommandProcessingResult settlePurchase(final Long purchaseId, final JsonCommand command) {
this.validator.validateForSettle(command.json());
final InvestmentPurchase p = this.purchaseRepository.findByIdWithLock(purchaseId)
.orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId));
if (!STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) {
throw rule("settle.not.pending", "Deal " + p.getDealNo() + " is not pending settlement");
}
p.setConfirmationReceivedOn(confirmationReceivedOn(command, p.getDealDate()));
p.setConfirmationRef(
StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(InvestmentApiConstants.CONFIRMATION_REF)));
final LocalDate today = DateUtils.getBusinessLocalDate();
if (p.getSettlementDate().isAfter(today)) {
throw rule("settle.before.date", "Deal " + p.getDealNo() + " can be settled on or after its settlement date "
@@ -420,6 +426,19 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return prefix + String.format("%06d", (max == null ? 0 : max) + 1);
}
/**
* Deal slip number of a sale, redemption or switch: FY start year + FY end (2 digits) + "S" + 5-digit running number,
* e.g. 202627S00001, so sales are numbered apart from purchases.
*/
static String nextSaleNo(final JdbcTemplate jdbcTemplate, final LocalDate dealDate) {
lockNumbering(jdbcTemplate, "m_investment_sale.sale_no");
final String prefix = dealNoPrefix(dealDate) + "S";
final Integer max = jdbcTemplate.queryForObject(
"SELECT MAX(CAST(SUBSTRING(sale_no FROM 8) AS INTEGER)) FROM m_investment_sale WHERE sale_no LIKE ?", Integer.class,
prefix + "%");
return prefix + String.format("%05d", (max == null ? 0 : max) + 1);
}
/** Financial year (April–March) of the deal date: 2026-04-01 … 2027-03-31 → "202627". */
static String dealNoPrefix(final LocalDate dealDate) {
final int fyStart = dealDate.getMonthValue() >= 4 ? dealDate.getYear() : dealDate.getYear() - 1;
@@ -462,6 +481,29 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur
return value == null ? BigDecimal.ZERO : value;
}
/**
* Date the counterparty's confirmation of a deal reached the back office, given on Settle (optional): not before the
* deal date and not after today.
*/
static LocalDate confirmationReceivedOn(final JsonCommand command, final LocalDate dealDate) {
final LocalDate on = command.parameterExists(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON)
? command.localDateValueOfParameterNamed(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON)
: null;
if (on == null) {
return null;
}
final DateTimeFormatter ddMmYyyy = DateTimeFormatter.ofPattern("dd-MM-yyyy");
if (on.isBefore(dealDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.confirmation.before.deal",
"The confirmation cannot be received before the deal date " + dealDate.format(ddMmYyyy));
}
if (on.isAfter(DateUtils.getBusinessLocalDate())) {
throw new GeneralPlatformDomainRuleException("error.msg.investment.confirmation.future",
"The confirmation received date cannot be after today");
}
return on;
}
private static GeneralPlatformDomainRuleException rule(final String code, final String message) {
return new GeneralPlatformDomainRuleException("error.msg.investmentpurchase." + code, message);
}
@@ -22,6 +22,7 @@ import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants;
import org.apache.fineract.portfolio.investment.data.InvestmentSaleData;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase;
import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository;
@@ -40,9 +41,10 @@ public class InvestmentSaleReadPlatformService {
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentSaleQuoter quoter;
/** Sales and redemptions, newest first — pending ones included. */
/** Sales and redemptions, newest first — pending ones included, dropped ones left out. */
public List<InvestmentSaleData> retrieveAll() {
return this.saleRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream().map(InvestmentSaleData::from).toList();
return this.saleRepository.findAll(Sort.by(Sort.Direction.DESC, "id")).stream()
.filter(s -> !InvestmentSaleApiConstants.STATUS_DROPPED.equals(s.getStatus())).map(InvestmentSaleData::from).toList();
}
/** What a sale would come to, before it is saved. */
@@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.AMOUNT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BANK_GL_ACCOUNT_ID;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKERAGE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.BROKER_CODE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_DATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.DEAL_TIME;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.LOSS_ON_SALE_GL_CODE;
@@ -35,6 +36,7 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiCons
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SALE_RATE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SANCTION_AUTHORITY;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.SETTLEMENT_TYPE;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_DROPPED;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_PENDING_SETTLEMENT;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.STATUS_SETTLED;
import static org.apache.fineract.portfolio.investment.api.InvestmentSaleApiConstants.TYPE_WRITEOFF;
@@ -45,6 +47,7 @@ import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
@@ -60,6 +63,7 @@ import org.apache.fineract.portfolio.investment.domain.InvestmentSaleRepository;
import org.apache.fineract.portfolio.investment.exception.InvestmentPartyNotFoundException;
import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException;
import org.apache.fineract.portfolio.investment.serialization.InvestmentSaleCommandFromApiJsonDeserializer;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -86,13 +90,19 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
private final InvestmentPurchaseRepository purchaseRepository;
private final InvestmentPartyRepository partyRepository;
private final InvestmentJournalPoster journalPoster;
private final JdbcTemplate jdbcTemplate;
@Override
@Transactional
public CommandProcessingResult createSale(final JsonCommand command) {
this.validator.validateForSell(command.json());
final InvestmentPurchase p = purchase(command.longValueOfParameterNamed(PURCHASE_ID));
final InvestmentSale sale = this.saleRepository.saveAndFlush(this.quoter.quoteSale(p, sellRequest(command), null, false));
final InvestmentSale quoted = this.quoter.quoteSale(p, sellRequest(command), null, false);
quoted.setSaleNo(InvestmentPurchaseWritePlatformServiceImpl.nextSaleNo(this.jdbcTemplate, quoted.getDealDate()));
quoted.setBrokerCode(StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(BROKER_CODE)));
quoted.setConfirmationMode(
StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(InvestmentApiConstants.CONFIRMATION_MODE)));
final InvestmentSale sale = this.saleRepository.saveAndFlush(quoted);
return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(sale.getId())
.withSubEntityId(p.getId()).withResourceIdAsString(p.getDealNo()).build();
}
@@ -100,6 +110,7 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
@Override
@Transactional
public CommandProcessingResult settleSale(final Long saleId, final JsonCommand command) {
this.validator.validateForSettle(command.json());
// Lock the deal before the sale, as every other posting on the deal does, so two settlements cannot interleave.
final InvestmentPurchase p = purchase(this.saleRepository.findPurchaseIdBySaleId(saleId)
.orElseThrow(() -> InvestmentSaleQuoter.rule("sale.not.found", "Investment sale " + saleId + " does not exist")));
@@ -129,6 +140,10 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
lines);
copyFigures(s, pending);
pending.setConfirmationReceivedOn(
InvestmentPurchaseWritePlatformServiceImpl.confirmationReceivedOn(command, pending.getDealDate()));
pending.setConfirmationRef(
StringUtils.trimToNull(command.stringValueOfParameterNamedAllowingNull(InvestmentApiConstants.CONFIRMATION_REF)));
pending.setStatus(STATUS_SETTLED);
pending.setGlTransactionId(posted.getTransactionId());
pending.setVoucherNo(voucherNo(posted));
@@ -148,13 +163,18 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
@Transactional
public CommandProcessingResult deleteSale(final Long saleId) {
final InvestmentSale s = sale(saleId);
if (STATUS_DROPPED.equals(s.getStatus())) {
throw InvestmentSaleQuoter.rule("sale.dropped", "This sale is already dropped");
}
if (!STATUS_PENDING_SETTLEMENT.equals(s.getStatus())) {
throw InvestmentSaleQuoter.rule("sale.settled", "This sale is already settled and posted to GL, so it cannot be dropped");
}
final Long purchaseId = s.getPurchase().getId();
this.saleRepository.delete(s);
this.saleRepository.flush();
return new CommandProcessingResultBuilder().withEntityId(saleId).withSubEntityId(purchaseId).build();
// Kept as DROPPED rather than deleted, so its sale number stays accounted for (shown as cancelled on the register).
s.setStatus(STATUS_DROPPED);
this.saleRepository.saveAndFlush(s);
return new CommandProcessingResultBuilder().withEntityId(saleId).withSubEntityId(purchaseId).withResourceIdAsString(s.getSaleNo())
.build();
}
@Override
@@ -175,6 +195,7 @@ public class InvestmentSaleWritePlatformServiceImpl implements InvestmentSaleWri
+ p.getScript().getScriptDesc(),
lines);
s.setSaleNo(InvestmentPurchaseWritePlatformServiceImpl.nextSaleNo(this.jdbcTemplate, s.getDealDate()));
s.setGlTransactionId(posted.getTransactionId());
s.setVoucherNo(voucherNo(posted));
s.setSettledOnDate(DateUtils.getBusinessLocalDate());
@@ -204,4 +204,5 @@
<include file="parts/0610_investment_valuation_and_fund_switch.xml" relativeToChangelogFile="true"/>
<include file="parts/0611_investment_maker_checker.xml" relativeToChangelogFile="true"/>
<include file="parts/0612_investment_share.xml" relativeToChangelogFile="true"/>
<include file="parts/0613_investment_deal_slip.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,74 @@
<?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">
<!--
Deal slips: every deal is serially numbered and carries the confirmation mode to the counterparty, the broker of a
broker deal, and the counterparty's confirmation the back office records on settlement. A purchase already has its
deal number; a sale, redemption or switch now gets its own sale number (financial year, "S", running number, e.g.
202627S00001). Write-offs and amortisation are not deals and get none. Existing sales are numbered in the order
they were entered within the financial year of their deal date.
-->
<changeSet author="cbs-anshul" id="0613-1-investment-purchase-confirmation">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_purchase" columnName="confirmation_mode"/></not>
</preConditions>
<addColumn tableName="m_investment_purchase">
<column name="confirmation_mode" type="VARCHAR(10)"/>
<column name="confirmation_received_on" type="DATE"/>
<column name="confirmation_ref" type="VARCHAR(100)"/>
</addColumn>
</changeSet>
<changeSet author="cbs-anshul" id="0613-2-investment-sale-deal-slip">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_investment_sale" columnName="sale_no"/></not>
</preConditions>
<addColumn tableName="m_investment_sale">
<column name="sale_no" type="VARCHAR(20)"/>
<column name="broker_code" type="VARCHAR(20)"/>
<column name="confirmation_mode" type="VARCHAR(10)"/>
<column name="confirmation_received_on" type="DATE"/>
<column name="confirmation_ref" type="VARCHAR(100)"/>
</addColumn>
</changeSet>
<changeSet author="cbs-anshul" id="0613-3-investment-sale-number-existing">
<sql>
UPDATE m_investment_sale s
SET sale_no = n.sale_no
FROM (SELECT b.id, b.fy || 'S' || LPAD(CAST(ROW_NUMBER() OVER (PARTITION BY b.fy ORDER BY b.id) AS VARCHAR), 5, '0') AS sale_no
FROM (SELECT a.id, CAST(a.fy_start AS VARCHAR) || LPAD(CAST(MOD(a.fy_start + 1, 100) AS VARCHAR), 2, '0') AS fy
FROM (SELECT id,
CASE WHEN EXTRACT(MONTH FROM deal_date) >= 4 THEN CAST(EXTRACT(YEAR FROM deal_date) AS INTEGER)
ELSE CAST(EXTRACT(YEAR FROM deal_date) AS INTEGER) - 1 END AS fy_start
FROM m_investment_sale
WHERE sale_type IN ('SELL', 'REDEEM', 'SWITCH') AND sale_no IS NULL) a) b) n
WHERE s.id = n.id
</sql>
</changeSet>
<changeSet author="cbs-anshul" id="0613-4-investment-sale-number-unique">
<preConditions onFail="MARK_RAN">
<not><indexExists tableName="m_investment_sale" indexName="uq_investment_sale_no"/></not>
</preConditions>
<addUniqueConstraint tableName="m_investment_sale" columnNames="sale_no" constraintName="uq_investment_sale_no"/>
</changeSet>
</databaseChangeLog>