diff --git a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java index d4c0153be..39c7f3e9d 100644 --- a/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java +++ b/fineract-core/src/main/java/org/apache/fineract/commands/service/CommandWrapperBuilder.java @@ -151,6 +151,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder settleInvestmentPurchase(final Long purchaseId) { + this.actionName = "SETTLE"; + this.entityName = "INVESTMENTPURCHASE"; + this.entityId = purchaseId; + this.href = "/investment/purchases/" + purchaseId + "/settle"; + return this; + } + public CommandWrapperBuilder createInvestmentDailyRate() { this.actionName = "CREATE"; this.entityName = "INVESTMENTDAILYRATE"; 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 c6bb013d8..2742d4a4f 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 @@ -31,8 +31,17 @@ public final class InvestmentPurchaseApiConstants { public static final Set SETTLEMENT_TYPES = Set.of("T+0", "T+1", "T+2"); public static final Set CATEGORIES = Set.of("HTM (Held To Maturity)", "AFS (Available For Sale)", "HFT (Held For Trading)"); + /** Saved deal; nothing posted to GL until it is settled. */ + public static final String STATUS_PENDING_SETTLEMENT = "PENDING_SETTLEMENT"; + /** Settled: posted to GL on the settlement date, now a holding. */ public static final String STATUS_ACTIVE = "ACTIVE"; + /** GLs the settlement entry debits besides the script's investment GL. */ + public static final String BROKEN_PERIOD_INTEREST_GL_CODE = "500606"; + public static final String STAMP_DUTY_GL_CODE = "500601"; + public static final String BROKERAGE_GL_CODE = "500602"; + public static final String OTHER_CHARGES_GL_CODE = "500603"; + public static final String BOND_TYPE = "bondType"; public static final String SCRIPT_ID = "scriptId"; public static final String PARTY_ID = "partyId"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java index 5aebaf4f6..790949c67 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentPurchaseApiResource.java @@ -95,6 +95,19 @@ public class InvestmentPurchaseApiResource { return this.commandsSourceWritePlatformService.logCommandSource(request); } + @POST + @Path("{purchaseId}/settle") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Settle a purchase", description = "On or after the settlement date: posts the purchase to GL dated the " + + "settlement date and makes the deal an active holding.") + public CommandProcessingResult settle(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().settleInvestmentPurchase(purchaseId) + .withJson(StringUtils.isBlank(apiRequestBodyAsJson) ? "{}" : apiRequestBodyAsJson).build(); + return this.commandsSourceWritePlatformService.logCommandSource(request); + } + @DELETE @Path("{purchaseId}") @Produces({ MediaType.APPLICATION_JSON }) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentPurchaseData.java index 75129d93e..01c8bd328 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 @@ -73,6 +73,8 @@ public class InvestmentPurchaseData { private final String crGlName; private final String otherDetails; private final String status; + private final String glTransactionId; + private final String settledOnDate; public static InvestmentPurchaseData from(final InvestmentPurchase p) { return InvestmentPurchaseData.builder().id(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()).bondType(p.getBondType()) @@ -88,7 +90,8 @@ 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()).build(); + .otherDetails(p.getOtherDetails()).status(p.getStatus()).glTransactionId(p.getGlTransactionId()) + .settledOnDate(format(p.getSettledOnDate())).build(); } private static String format(final LocalDate date) { 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 38858fa29..a65469f39 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 @@ -148,4 +148,11 @@ public class InvestmentPurchase extends AbstractAuditableWithUTCDateTimeCustom schedule(final BigDecimal faceValue, final BigDecimal ratePercent, final LocalDate maturity, final LocalDate lastIntDate, final LocalDate dealDate) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java index fc63727ac..e5848d999 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseReadPlatformService.java @@ -66,7 +66,7 @@ public class InvestmentPurchaseReadPlatformService { final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); final List rows = InvestmentInterestCalculator - .schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getDealDate()).stream() + .schedule(faceHeld, rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate()).stream() .map(r -> InvestmentInterestScheduleData.Row.builder().type(r.type()).intDate(format(r.date())) .fromDate(format(r.fromDate())).toDate(format(r.toDate())).days(r.days()).grossInterest(r.grossInterest()) .alreadyBooked(r.alreadyBooked()).amount(r.amount()).due(!r.date().isAfter(asOn)).build()) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformService.java index 61d495d21..30ca1e95e 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentPurchaseWritePlatformService.java @@ -26,4 +26,6 @@ public interface InvestmentPurchaseWritePlatformService { CommandProcessingResult createPurchase(JsonCommand command); CommandProcessingResult deletePurchase(Long purchaseId); + + CommandProcessingResult settlePurchase(Long purchaseId, JsonCommand command); } 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 9a557ebb9..796688810 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 @@ -19,7 +19,9 @@ package org.apache.fineract.portfolio.investment.service; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BOND_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKEN_PERIOD_INTEREST_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.BROKERAGE_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CATEGORY; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CONSIDER_AMT; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.CR_GL_ACCOUNT_ID; @@ -29,29 +31,39 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.LAST_INT_DATE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.NAV; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_CHARGES; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_CHARGES_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.OTHER_DETAILS; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PARTY_ID; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.PURCHASE_RATE; 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.STAMP_DUTY; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STAMP_DUTY_GL_CODE; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_PENDING_SETTLEMENT; import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.SUB_TYPE; +import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import com.google.gson.JsonObject; import java.math.BigDecimal; import java.math.RoundingMode; import java.time.LocalDate; +import java.time.format.DateTimeFormatter; import java.time.temporal.ChronoUnit; +import java.util.Locale; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.apache.fineract.accounting.glaccount.domain.GLAccount; import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.accounting.glaccount.domain.GLAccountType; +import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService; import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; +import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; +import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.portfolio.investment.domain.InvestmentParty; import org.apache.fineract.portfolio.investment.domain.InvestmentPartyRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; @@ -82,6 +94,8 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur private final InvestmentPartyRepository partyRepository; private final GLAccountRepositoryWrapper glAccountRepository; private final JdbcTemplate jdbcTemplate; + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; @Override @Transactional @@ -130,7 +144,7 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur p.setInvestmentGlAccount(script.getGlAccount()); p.setCrGlAccount(postableAssetGl(command.longValueOfParameterNamed(CR_GL_ACCOUNT_ID))); p.setOtherDetails(otherDetails(command)); - p.setStatus(STATUS_ACTIVE); + p.setStatus(STATUS_PENDING_SETTLEMENT); // Brokerage, stamp duty and other charges are expenses of the deal (Jitendra Sir): paid with the // settlement, but kept out of the book value so the charges stay visible on their own GLs. @@ -154,6 +168,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur public CommandProcessingResult deletePurchase(final Long purchaseId) { final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId) .orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + if (!STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) { + throw rule("purchase.settled", "Deal " + p.getDealNo() + " is already settled and posted to GL, so it cannot be dropped"); + } if (p.getBalanceUnits().compareTo(p.getUnits()) != 0) { throw rule("purchase.partly.sold", "Deal " + p.getDealNo() + " has been partly sold and cannot be dropped"); } @@ -162,6 +179,78 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur return new CommandProcessingResultBuilder().withEntityId(purchaseId).withResourceIdAsString(p.getDealNo()).build(); } + /** + * Settles a deal: on or after its settlement date the money has gone and the security has come in, so the purchase + * is posted to GL dated the settlement date (settlement-date accounting) and the deal becomes a holding. Posted + * through the journal entry command like any manual entry, inside this transaction, so under maker-checker nothing + * reaches the GL until the settlement is approved. + */ + @Override + @Transactional + public CommandProcessingResult settlePurchase(final Long purchaseId, final JsonCommand command) { + final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId) + .orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + if (!STATUS_PENDING_SETTLEMENT.equals(p.getStatus())) { + throw rule("settle.not.pending", "Deal " + p.getDealNo() + " is not pending settlement"); + } + 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 " + + p.getSettlementDate().format(DateTimeFormatter.ofPattern("dd-MM-yyyy"))); + } + + final JsonArray debits = new JsonArray(); + addLine(debits, p.getInvestmentGlAccount().getId(), p.getBookValue()); + addLine(debits, glId(BROKEN_PERIOD_INTEREST_GL_CODE), p.getAccruedInterest()); + addLine(debits, glId(STAMP_DUTY_GL_CODE), p.getStampDuty()); + addLine(debits, glId(BROKERAGE_GL_CODE), p.getBrokerage()); + addLine(debits, glId(OTHER_CHARGES_GL_CODE), p.getOtherCharges()); + final JsonArray credits = new JsonArray(); + addLine(credits, p.getCrGlAccount().getId(), p.getSettlementAmount()); + + final String dateFormat = "dd MMMM yyyy"; + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", headOfficeId()); + payload.addProperty("transactionDate", p.getSettlementDate().format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH))); + payload.addProperty("dateFormat", dateFormat); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", "INR"); + payload.addProperty("referenceNumber", p.getDealNo()); + payload.addProperty("comments", "Investment purchase settled: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " + + p.getScript().getScriptDesc() + ", " + p.getParty().getPartyName()); + payload.add("debits", debits); + payload.add("credits", credits); + final String json = payload.toString(); + final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null, + null, null, null, null, null, null, null, null, null, null, null, null, null); + final CommandProcessingResult posted = this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand); + + p.setGlTransactionId(posted.getTransactionId()); + p.setSettledOnDate(today); + p.setStatus(STATUS_ACTIVE); + this.purchaseRepository.saveAndFlush(p); + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(purchaseId) + .withResourceIdAsString(p.getDealNo()).withTransactionId(posted.getTransactionId()).build(); + } + + private static void addLine(final JsonArray lines, final Long glAccountId, final BigDecimal amount) { + if (amount != null && amount.signum() > 0) { + final JsonObject line = new JsonObject(); + line.addProperty("glAccountId", glAccountId); + line.addProperty("amount", amount); + lines.add(line); + } + } + + private Long glId(final String glCode) { + return this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(glCode).getId(); + } + + /** Investments are a Head Office function, so the settlement entry is booked there. */ + private Long headOfficeId() { + return this.jdbcTemplate.queryForObject("SELECT id FROM m_office WHERE parent_id IS NULL ORDER BY id LIMIT 1", Long.class); + } + /** Govt Security / Bond: face value at a price per ₹100, plus interest owed to the seller since the last coupon. */ private void computeSecurity(final InvestmentPurchase p, final JsonCommand command, final InvestmentScript script) { final BigDecimal faceValue = command.bigDecimalValueOfParameterNamed(FACE_VALUE); @@ -172,9 +261,9 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur } final BigDecimal units = faceValue.divide(script.getFaceValuePerUnit(), 6, RoundingMode.HALF_UP); final BigDecimal cost = faceValue.multiply(rate).divide(HUNDRED, 2, RoundingMode.HALF_UP); - final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getDealDate()); + // Broken-period interest runs to the settlement date, when the bond leaves the seller (RBI 83, Jitendra Sir). + final int days = InvestmentInterestCalculator.days360(lastIntDate, p.getSettlementDate()); final BigDecimal intRate = zeroIfNull(script.getIntRate()); - // Interest is counted up to the deal date (the event), not the settlement date — Jitendra Sir. final BigDecimal accrued = InvestmentInterestCalculator.interestForDays(faceValue, intRate, days); p.setFaceValue(faceValue); 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 bffc1aa86..c48692b59 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 @@ -171,4 +171,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_investment_purchase_settlement.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_investment_purchase_settlement.xml new file mode 100644 index 000000000..671bee99b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0597_investment_purchase_settlement.xml @@ -0,0 +1,74 @@ + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('SETTLE_INVESTMENTPURCHASE', 'SETTLE_INVESTMENTPURCHASE_CHECKER') + + + + + + + + + + + + + + + + + + + + + INSERT INTO m_role_permission (role_id, permission_id) + SELECT rp.role_id, p.id + FROM m_role_permission rp + JOIN m_permission menu ON menu.id = rp.permission_id AND menu.code = 'MENU_INVESTMENT' + CROSS JOIN m_permission p + WHERE p.code IN ('SETTLE_INVESTMENTPURCHASE', 'SETTLE_INVESTMENTPURCHASE_CHECKER') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + +