diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java index acad2cacb..556143f34 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentApiConstants.java @@ -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 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"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDealSlipApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDealSlipApiResource.java new file mode 100644 index 000000000..4de76a573 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentDealSlipApiResource.java @@ -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 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java index 486b62ed8..d7a2f7cc7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiConstants.java @@ -72,7 +72,10 @@ public final class InvestmentPurchaseApiConstants { public static final Set 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 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"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java index 87f3cfaa5..77c593568 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentSaleApiConstants.java @@ -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 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 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 SETTLE_PARAMS = Set.of(InvestmentApiConstants.CONFIRMATION_RECEIVED_ON, + InvestmentApiConstants.CONFIRMATION_REF, LOCALE, DATE_FORMAT); public static final Set REDEEM_PARAMS = Set.of(BANK_GL_ACCOUNT_ID, REMARKS, LOCALE, DATE_FORMAT); public static final Set WRITEOFF_PARAMS = Set.of(AMOUNT, REMARKS, LOCALE, DATE_FORMAT); public static final Set AMORTISE_PARAMS = Set.of(PURCHASE_ID, FY_END, LOCALE, DATE_FORMAT); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDealSlipData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDealSlipData.java new file mode 100644 index 000000000..53f902c23 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentDealSlipData.java @@ -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; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java index 01c8bd328..2375d7dbb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java @@ -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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentSaleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentSaleData.java index 5d1012dd7..35cf81cfc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentSaleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentSaleData.java @@ -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(); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchase.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchase.java index eae8c1c65..ccdf7632b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchase.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentPurchase.java @@ -151,6 +151,17 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom findByDealDateBetween(LocalDate from, LocalDate to); + + Optional findByDealNo(String dealNo); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSale.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSale.java index aae70b03f..bf96455e6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSale.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSale.java @@ -43,6 +43,13 @@ public class InvestmentSale extends AbstractAuditableWithUTCDateTimeCustom @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 @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; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSaleRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSaleRepository.java index 564726f50..1ac31f01b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSaleRepository.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentSaleRepository.java @@ -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 findByIdWithLock(@Param("saleId") Long saleId); + /** Every numbered sale, redemption and switch dated in the period, dropped ones included (the deal slip register). */ + List findBySaleNoNotNullAndDealDateBetween(LocalDate from, LocalDate to); + + Optional 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 findPurchaseIdBySaleId(@Param("saleId") Long saleId); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentPurchaseCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentPurchaseCommandFromApiJsonDeserializer.java index b3cf145e8..4eb2fa483 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentPurchaseCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentPurchaseCommandFromApiJsonDeserializer.java @@ -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>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, SETTLE_PARAMS); + final JsonElement element = this.fromApiJsonHelper.parse(json); + final List 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) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentSaleCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentSaleCommandFromApiJsonDeserializer.java index f5e6d0dd9..837913dfa 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentSaleCommandFromApiJsonDeserializer.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentSaleCommandFromApiJsonDeserializer.java @@ -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 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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDealSlipService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDealSlipService.java new file mode 100644 index 000000000..d23abadec --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentDealSlipService.java @@ -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 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 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 slips(final List purchases, final List sales) { + final Map actions = actions(purchases.stream().map(InvestmentPurchase::getId).toList(), + sales.stream().map(InvestmentSale::getId).toList()); + final List 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 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 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 actions(final Collection purchaseIds, final Collection saleIds) { + final Map 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 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; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java index bc2f5a967..9a11b8d5a 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentFundSwitchService.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java index c08ac4d16..6bbc51ed6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformServiceImpl.java @@ -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); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleReadPlatformService.java index 1c609f11f..3bbf770cc 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleReadPlatformService.java @@ -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 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. */ diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java index e6c4120d9..739eb2f9b 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentSaleWritePlatformServiceImpl.java @@ -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()); diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 620c6b5a2..6f6571181 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -204,4 +204,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0613_investment_deal_slip.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0613_investment_deal_slip.xml new file mode 100644 index 000000000..4f8bb184d --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0613_investment_deal_slip.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 + + + + + + + + +