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 84190bce0..e5d9c04a9 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 @@ -159,6 +159,14 @@ public class CommandWrapperBuilder { return this; } + public CommandWrapperBuilder postInvestmentInterest(final Long purchaseId) { + this.actionName = "CREATE"; + this.entityName = "INVESTMENTINTEREST"; + this.entityId = purchaseId; + this.href = "/investment/purchases/" + purchaseId + "/interest-postings"; + return this; + } + public CommandWrapperBuilder createInvestmentDailyRate() { this.actionName = "CREATE"; this.entityName = "INVESTMENTDAILYRATE"; diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java index c93b3918e..bd63a0a72 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/branchday/api/BranchDayRegisterApiResource.java @@ -46,6 +46,7 @@ import org.apache.fineract.infrastructure.core.service.DateUtils; import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; import org.apache.fineract.portfolio.cashreport.service.VaultPositionService; import org.apache.fineract.portfolio.denominationmaster.service.CountedCashLedger; +import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsDailyInterestOrchestrator; import org.apache.fineract.portfolio.savings.dailyinterest.SavingsInterestPostingReportService; import org.apache.fineract.portfolio.savings.fdinterest.FixedDepositInterestPostingOrchestrator; @@ -95,8 +96,15 @@ public class BranchDayRegisterApiResource { private final SavingsDailyInterestOrchestrator savingsDailyInterestOrchestrator; private final FixedDepositInterestPostingReportService fdInterestPostingReportService; private final FixedDepositInterestPostingOrchestrator fdInterestPostingOrchestrator; + private final InvestmentPurchaseReadPlatformService investmentPurchaseReadPlatformService; private final Gson gson = new Gson(); + private boolean isHeadOffice(final Long officeId) { + final Integer topLevel = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_office WHERE id = ? AND parent_id IS NULL", + Integer.class, officeId); + return topLevel != null && topLevel > 0; + } + // ── GET /v1/branchday ── the register itself ────────────────────────────── /** @@ -260,6 +268,11 @@ public class BranchDayRegisterApiResource { final Map fdInterest = fdInterestPostingReportService.dayCloseInterestView(officeId, businessDate); result.put("fdInterestPending", fdInterest.get("pending")); result.put("fdInterestDue", fdInterest.get("due")); + // Investment interest vouchers (coupon / provision) whose date has come but which are not posted yet. + // A reminder only: it never stops the close (Jitendra Sir). Investments are booked at Head Office, so + // only the Head Office close shows it. + result.put("investmentInterestPending", + isHeadOffice(officeId) ? investmentPurchaseReadPlatformService.retrieveDueInterest(0) : List.of()); return gson.toJson(result); } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiConstants.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiConstants.java new file mode 100644 index 000000000..2f746bca7 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiConstants.java @@ -0,0 +1,51 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.api; + +import java.util.Set; + +/** Constants for posting an interest schedule line (coupon or closing provision) of a holding to GL. */ +public final class InvestmentInterestApiConstants { + + private InvestmentInterestApiConstants() {} + + public static final String ENTITY = "INVESTMENTINTEREST"; + public static final String RESOURCE = "investmentinterest"; + + public static final String LINE_TYPE = "lineType"; + public static final String INT_DATE = "intDate"; + /** Bank GL the coupon is received into; defaults to the purchase's paying bank. */ + public static final String BANK_GL_ACCOUNT_ID = "bankGlAccountId"; + public static final String LOCALE = "locale"; + public static final String DATE_FORMAT = "dateFormat"; + + public static final Set PARAMS = Set.of(LINE_TYPE, INT_DATE, BANK_GL_ACCOUNT_ID, LOCALE, DATE_FORMAT); + + /** A line shows as a task this many days before its date (Jitendra Sir). */ + public static final int DUE_WITHIN_DAYS = 2; + + public static final String INTEREST_RECEIVABLE_GL_CODE = "100603"; + public static final String INTEREST_INCOME_GOVT_SECURITIES_GL_CODE = "400401"; + public static final String INTEREST_INCOME_BONDS_GL_CODE = "400402"; + + /** Income GL for a holding's interest: SLR govt securities, or bonds / debentures for the rest. */ + public static String interestIncomeGlCode(final String bondType) { + return "GOVT_SECURITY".equals(bondType) ? INTEREST_INCOME_GOVT_SECURITIES_GL_CODE : INTEREST_INCOME_BONDS_GL_CODE; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiResource.java new file mode 100644 index 000000000..945c73ff9 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/api/InvestmentInterestApiResource.java @@ -0,0 +1,55 @@ +/** + * 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.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData; +import org.apache.fineract.portfolio.investment.service.InvestmentPurchaseReadPlatformService; +import org.springframework.stereotype.Component; + +@Path("/v1/investment/interest-postings") +@Component +@Tag(name = "Investment Interest Posting", description = "Interest lines of holdings that are due to be posted with a voucher.") +@RequiredArgsConstructor +public class InvestmentInterestApiResource { + + private final PlatformSecurityContext context; + private final InvestmentPurchaseReadPlatformService readPlatformService; + + @GET + @Path("due") + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Interest lines due", description = "For every active holding, its next unposted interest line when that " + + "line's date is past or within withinDays days (default 2).") + public List due(@QueryParam("withinDays") @Parameter(description = "withinDays") final Integer withinDays) { + this.context.authenticatedUser().validateHasReadPermission(InvestmentInterestApiConstants.ENTITY); + return this.readPlatformService + .retrieveDueInterest(withinDays == null ? InvestmentInterestApiConstants.DUE_WITHIN_DAYS : withinDays); + } +} 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 790949c67..46c0c8257 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 @@ -108,6 +108,19 @@ public class InvestmentPurchaseApiResource { return this.commandsSourceWritePlatformService.logCommandSource(request); } + @POST + @Path("{purchaseId}/interest-postings") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + @Operation(summary = "Post an interest line", description = "Posts the next interest schedule line (coupon or closing provision) " + + "of a holding to GL with a JV voucher, on or after its date and in date order.") + public CommandProcessingResult postInterest(@PathParam("purchaseId") @Parameter(description = "purchaseId") final Long purchaseId, + @Parameter(hidden = true) final String apiRequestBodyAsJson) { + final CommandWrapper request = new CommandWrapperBuilder().postInvestmentInterest(purchaseId).withJson(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/InvestmentInterestDueData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestDueData.java new file mode 100644 index 000000000..af00d09b8 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestDueData.java @@ -0,0 +1,43 @@ +/** + * 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; + +/** The next unposted interest line of a holding that is due, or due within the reminder window — a task to post. */ +@Getter +@Builder +public class InvestmentInterestDueData { + + private final Long purchaseId; + private final String dealNo; + private final String certNo; + private final String scriptCode; + private final String scriptDesc; + private final String lineType; + private final String intDate; + private final BigDecimal grossInterest; + private final BigDecimal amount; + /** The date has come: it can be posted now. */ + private final boolean postable; + /** The date has passed without posting. */ + private final boolean overdue; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java index 4ea5ba2ce..f288f1804 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/data/InvestmentInterestScheduleData.java @@ -53,6 +53,11 @@ public class InvestmentInterestScheduleData { private final String asOnDate; private final String interestGlCode; private final String interestGlName; + /** Bank the coupon is received into by default: the purchase's paying bank. */ + private final Long bankGlAccountId; + private final String bankGlCode; + private final String bankGlName; + private final String status; private final List rows; @Getter @@ -73,5 +78,14 @@ public class InvestmentInterestScheduleData { private final BigDecimal amount; /** True when the date is on or before the as-on date. */ private final boolean due; + /** Posted to GL: its JV voucher and GL transaction. */ + private final boolean posted; + private final String voucherNo; + private final String glTransactionId; + private final String postedOnDate; + /** The next line to post and its date has come. */ + private final boolean postable; + /** Unposted and its date is within the reminder window (or past). */ + private final boolean dueSoon; } } diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPosting.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPosting.java new file mode 100644 index 000000000..ec71d6855 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPosting.java @@ -0,0 +1,84 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.math.BigDecimal; +import java.time.LocalDate; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.infrastructure.core.domain.AbstractAuditableWithUTCDateTimeCustom; + +/** One interest schedule line of a holding (coupon received or closing provision) that has been posted to GL. */ +@Entity +@Table(name = "m_investment_interest_posting") +@Getter +@Setter +@NoArgsConstructor +public class InvestmentInterestPosting extends AbstractAuditableWithUTCDateTimeCustom { + + @ManyToOne + @JoinColumn(name = "purchase_id", nullable = false) + private InvestmentPurchase purchase; + + /** COUPON or PROVISION. */ + @Column(name = "line_type", nullable = false, length = 20) + private String lineType; + + @Column(name = "int_date", nullable = false) + private LocalDate intDate; + + @Column(name = "from_date") + private LocalDate fromDate; + + @Column(name = "to_date") + private LocalDate toDate; + + @Column(name = "days") + private Integer days; + + @Column(name = "gross_interest", scale = 6, precision = 19) + private BigDecimal grossInterest; + + @Column(name = "already_booked", scale = 6, precision = 19) + private BigDecimal alreadyBooked; + + @Column(name = "amount", scale = 6, precision = 19, nullable = false) + private BigDecimal amount; + + /** Bank the coupon was received into (coupon lines only). */ + @ManyToOne + @JoinColumn(name = "bank_gl_account_id") + private GLAccount bankGlAccount; + + @Column(name = "gl_transaction_id", length = 50) + private String glTransactionId; + + @Column(name = "voucher_no", length = 30) + private String voucherNo; + + @Column(name = "posted_on_date") + private LocalDate postedOnDate; +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPostingRepository.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPostingRepository.java new file mode 100644 index 000000000..80a7aea27 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/domain/InvestmentInterestPostingRepository.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.domain; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface InvestmentInterestPostingRepository extends JpaRepository { + + List findByPurchaseIdOrderByIntDateAsc(Long purchaseId); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentInterestCommandHandler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentInterestCommandHandler.java new file mode 100644 index 000000000..1871ac5ab --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/handler/PostInvestmentInterestCommandHandler.java @@ -0,0 +1,42 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.handler; + +import lombok.RequiredArgsConstructor; +import org.apache.fineract.commands.annotation.CommandType; +import org.apache.fineract.commands.handler.NewCommandSourceHandler; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; +import org.apache.fineract.portfolio.investment.service.InvestmentInterestPostingWritePlatformService; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@CommandType(entity = "INVESTMENTINTEREST", action = "CREATE") +@RequiredArgsConstructor +public class PostInvestmentInterestCommandHandler implements NewCommandSourceHandler { + + private final InvestmentInterestPostingWritePlatformService writePlatformService; + + @Transactional + @Override + public CommandProcessingResult processCommand(final JsonCommand command) { + return this.writePlatformService.postInterest(command.entityId(), command); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentInterestCommandFromApiJsonDeserializer.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentInterestCommandFromApiJsonDeserializer.java new file mode 100644 index 000000000..7b4d90f63 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/serialization/InvestmentInterestCommandFromApiJsonDeserializer.java @@ -0,0 +1,73 @@ +/** + * 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.serialization; + +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.INT_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.LINE_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.PARAMS; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.RESOURCE; + +import com.google.gson.JsonElement; +import com.google.gson.reflect.TypeToken; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.commons.lang3.StringUtils; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +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.service.InvestmentInterestCalculator; +import org.springframework.stereotype.Component; + +/** Validates a request to post one interest schedule line. */ +@Component +@RequiredArgsConstructor +public final class InvestmentInterestCommandFromApiJsonDeserializer { + + private final FromJsonHelper fromApiJsonHelper; + + public void validateForPost(final String json) { + if (StringUtils.isBlank(json)) { + throw new InvalidJsonException(); + } + final Type typeOfMap = new TypeToken>() {}.getType(); + this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, PARAMS); + final JsonElement element = this.fromApiJsonHelper.parse(json); + + final List errors = new ArrayList<>(); + final DataValidatorBuilder v = new DataValidatorBuilder(errors).resource(RESOURCE); + final String lineType = this.fromApiJsonHelper.extractStringNamed(LINE_TYPE, element); + v.reset().parameter(LINE_TYPE).value(lineType).notBlank(); + if (StringUtils.isNotBlank(lineType)) { + v.reset().parameter(LINE_TYPE).value(lineType).isOneOfTheseValues(InvestmentInterestCalculator.TYPE_COUPON, + InvestmentInterestCalculator.TYPE_PROVISION); + } + v.reset().parameter(INT_DATE).value(this.fromApiJsonHelper.extractLocalDateNamed(INT_DATE, element)).notNull(); + v.reset().parameter(BANK_GL_ACCOUNT_ID).value(this.fromApiJsonHelper.extractLongNamed(BANK_GL_ACCOUNT_ID, element)).ignoreIfNull() + .longGreaterThanZero(); + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformService.java new file mode 100644 index 000000000..0cdd37eed --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformService.java @@ -0,0 +1,27 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.service; + +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; + +public interface InvestmentInterestPostingWritePlatformService { + + CommandProcessingResult postInterest(Long purchaseId, JsonCommand command); +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformServiceImpl.java new file mode 100644 index 000000000..997303a1b --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentInterestPostingWritePlatformServiceImpl.java @@ -0,0 +1,145 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.fineract.portfolio.investment.service; + +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.BANK_GL_ACCOUNT_ID; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.INTEREST_RECEIVABLE_GL_CODE; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.INT_DATE; +import static org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants.LINE_TYPE; +import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants.STATUS_ACTIVE; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +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.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.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants; +import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPosting; +import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPostingRepository; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; +import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; +import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException; +import org.apache.fineract.portfolio.investment.serialization.InvestmentInterestCommandFromApiJsonDeserializer; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * Posts one interest schedule line of a holding to GL with its JV voucher, on or after its date and in date order: + *
    + *
  • provision (31 March / 30 September): Dr Interest Receivable / Cr Interest Income;
  • + *
  • coupon: Dr Bank (full coupon) / Cr Interest Receivable (what the provision booked) / Cr Interest Income (the + * rest).
  • + *
+ * The amounts come from the schedule, never from the request, so the voucher always matches what the screen shows. + */ +@Service +@RequiredArgsConstructor +public class InvestmentInterestPostingWritePlatformServiceImpl implements InvestmentInterestPostingWritePlatformService { + + private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy"); + + private final InvestmentInterestCommandFromApiJsonDeserializer validator; + private final InvestmentPurchaseRepository purchaseRepository; + private final InvestmentInterestPostingRepository postingRepository; + private final InvestmentPurchaseReadPlatformService readPlatformService; + private final InvestmentJournalPoster journalPoster; + private final GLAccountRepositoryWrapper glAccountRepository; + + @Override + @Transactional + public CommandProcessingResult postInterest(final Long purchaseId, final JsonCommand command) { + this.validator.validateForPost(command.json()); + final InvestmentPurchase p = this.purchaseRepository.findById(purchaseId) + .orElseThrow(() -> new InvestmentPurchaseNotFoundException(purchaseId)); + if (!STATUS_ACTIVE.equals(p.getStatus())) { + throw rule("not.active", "Deal " + p.getDealNo() + " is not an active (settled) holding"); + } + final String lineType = command.stringValueOfParameterNamed(LINE_TYPE); + final LocalDate intDate = command.localDateValueOfParameterNamed(INT_DATE); + if (intDate.isAfter(DateUtils.getBusinessLocalDate())) { + throw rule("before.date", "This interest line can be posted on or after " + intDate.format(DATE)); + } + + final List posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(purchaseId); + final InvestmentInterestCalculator.ScheduleRow next = this.readPlatformService.scheduleRows(p).stream() + .filter(r -> posted.stream().noneMatch(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date()))) + .findFirst().orElseThrow(() -> rule("all.posted", "All interest lines of deal " + p.getDealNo() + " are already posted")); + if (!next.type().equals(lineType) || !next.date().equals(intDate)) { + throw rule("out.of.order", "Post the interest lines in date order: next for deal " + p.getDealNo() + " is the " + + next.type().toLowerCase() + " of " + next.date().format(DATE)); + } + + final Long receivableGl = this.journalPoster.glId(INTEREST_RECEIVABLE_GL_CODE); + final Long incomeGl = this.journalPoster.glId(InvestmentInterestApiConstants.interestIncomeGlCode(p.getBondType())); + final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines(); + GLAccount bankGl = null; + if (InvestmentInterestCalculator.TYPE_COUPON.equals(lineType)) { + bankGl = command.parameterExists(BANK_GL_ACCOUNT_ID) && command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID) != null + ? postableAssetGl(command.longValueOfParameterNamed(BANK_GL_ACCOUNT_ID)) + : p.getCrGlAccount(); + lines.debit(bankGl.getId(), next.grossInterest()).credit(receivableGl, next.alreadyBooked()).credit(incomeGl, next.amount()); + } else { + lines.debit(receivableGl, next.amount()).credit(incomeGl, next.amount()); + } + final String what = InvestmentInterestCalculator.TYPE_COUPON.equals(lineType) ? "Coupon received" : "Interest provision"; + final CommandProcessingResult result = this.journalPoster.post(intDate, p.getDealNo(), what + " " + intDate.format(DATE) + ": deal " + + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " + p.getScript().getScriptDesc(), lines); + + final InvestmentInterestPosting posting = new InvestmentInterestPosting(); + posting.setPurchase(p); + posting.setLineType(lineType); + posting.setIntDate(intDate); + posting.setFromDate(next.fromDate()); + posting.setToDate(next.toDate()); + posting.setDays(next.days()); + posting.setGrossInterest(next.grossInterest()); + posting.setAlreadyBooked(next.alreadyBooked()); + posting.setAmount(next.amount()); + posting.setBankGlAccount(bankGl); + posting.setGlTransactionId(result.getTransactionId()); + final Object voucherNo = result.getChanges() == null ? null : result.getChanges().get("voucherNo"); + posting.setVoucherNo(voucherNo == null ? null : voucherNo.toString()); + posting.setPostedOnDate(DateUtils.getBusinessLocalDate()); + final InvestmentInterestPosting saved = this.postingRepository.saveAndFlush(posting); + + return new CommandProcessingResultBuilder().withCommandId(command.commandId()).withEntityId(saved.getId()) + .withSubEntityId(purchaseId).withResourceIdAsString(posting.getVoucherNo()).withTransactionId(result.getTransactionId()) + .build(); + } + + /** A bank the coupon is received into must be an enabled ASSET detail GL. */ + private GLAccount postableAssetGl(final Long glAccountId) { + final GLAccount gl = this.glAccountRepository.findOneWithNotFoundDetection(glAccountId); + if (gl.isHeaderAccount() || gl.isDisabled() || !GLAccountType.ASSET.getValue().equals(gl.getType())) { + throw rule("bank.glaccount.invalid", "Bank GL " + gl.getGlCode() + " must be an enabled Asset detail account"); + } + return gl; + } + + private static GeneralPlatformDomainRuleException rule(final String code, final String message) { + return new GeneralPlatformDomainRuleException("error.msg.investmentinterest." + code, message); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentJournalPoster.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentJournalPoster.java new file mode 100644 index 000000000..9879c1d1a --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/investment/service/InvestmentJournalPoster.java @@ -0,0 +1,105 @@ +/** + * 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.JsonArray; +import com.google.gson.JsonObject; +import java.math.BigDecimal; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; +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.serialization.FromJsonHelper; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * Posts investment entries to GL through the journal entry command, like any manual entry: it checks the GLs and + * accounting closures, joins the caller's transaction (so under maker-checker nothing persists until approval) and + * raises the JV voucher. Investments are a Head Office function, so every entry is booked there, in INR. + */ +@Component +@RequiredArgsConstructor +public class InvestmentJournalPoster { + + private static final String DATE_FORMAT = "dd MMMM yyyy"; + + private final JournalEntryWritePlatformService journalEntryWritePlatformService; + private final FromJsonHelper fromApiJsonHelper; + private final GLAccountRepositoryWrapper glAccountRepository; + private final JdbcTemplate jdbcTemplate; + + /** Debit and credit lines of one entry; zero or missing amounts are left out. */ + public static final class Lines { + + private final JsonArray debits = new JsonArray(); + private final JsonArray credits = new JsonArray(); + + public Lines debit(final Long glAccountId, final BigDecimal amount) { + add(this.debits, glAccountId, amount); + return this; + } + + public Lines credit(final Long glAccountId, final BigDecimal amount) { + add(this.credits, glAccountId, amount); + return this; + } + + private static void add(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); + } + } + } + + public Long glId(final String glCode) { + return this.glAccountRepository.findOneByGlCodeWithNotFoundDetection(glCode).getId(); + } + + /** + * Posts the entry dated {@code date}; the result carries the GL transaction id and, in its changes, the voucherNo. + */ + public CommandProcessingResult post(final LocalDate date, final String referenceNumber, final String comments, final Lines lines) { + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", headOfficeId()); + payload.addProperty("transactionDate", date.format(DateTimeFormatter.ofPattern(DATE_FORMAT, Locale.ENGLISH))); + payload.addProperty("dateFormat", DATE_FORMAT); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", "INR"); + payload.addProperty("referenceNumber", referenceNumber); + payload.addProperty("comments", comments); + payload.add("debits", lines.debits); + payload.add("credits", lines.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); + return this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand); + } + + private Long headOfficeId() { + return this.jdbcTemplate.queryForObject("SELECT id FROM m_office WHERE parent_id IS NULL ORDER BY id LIMIT 1", Long.class); + } +} 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 e5848d999..cce167f75 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 @@ -21,11 +21,20 @@ package org.apache.fineract.portfolio.investment.service; import java.math.BigDecimal; import java.time.LocalDate; import java.time.format.DateTimeFormatter; +import java.util.ArrayList; import java.util.List; +import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.glaccount.domain.GLAccount; +import org.apache.fineract.accounting.glaccount.domain.GLAccountRepositoryWrapper; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.apache.fineract.portfolio.investment.api.InvestmentInterestApiConstants; +import org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApiConstants; +import org.apache.fineract.portfolio.investment.data.InvestmentInterestDueData; import org.apache.fineract.portfolio.investment.data.InvestmentInterestScheduleData; import org.apache.fineract.portfolio.investment.data.InvestmentPurchaseData; +import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPosting; +import org.apache.fineract.portfolio.investment.domain.InvestmentInterestPostingRepository; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchase; import org.apache.fineract.portfolio.investment.domain.InvestmentPurchaseRepository; import org.apache.fineract.portfolio.investment.exception.InvestmentPurchaseNotFoundException; @@ -39,11 +48,10 @@ import org.springframework.transaction.annotation.Transactional; public class InvestmentPurchaseReadPlatformService { private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy"); - /** Income head for coupon and provision interest (GL 400401 under 400400 INCOME FROM INVESTMENTS). */ - private static final String INTEREST_GL_CODE = "400401"; - private static final String INTEREST_GL_NAME = "INTEREST ON GOVT SECURITIES"; private final InvestmentPurchaseRepository purchaseRepository; + private final InvestmentInterestPostingRepository postingRepository; + private final GLAccountRepositoryWrapper glAccountRepository; /** All deals, newest first — the Holdings register. */ public List retrieveAll() { @@ -56,29 +64,85 @@ public class InvestmentPurchaseReadPlatformService { /** * Coupon and closing-provision lines of a holding from purchase to maturity, worked out on the face value still - * held. Lines on or before {@code asOnDate} (default: business date) are marked due. + * held, with what has been posted. Lines on or before {@code asOnDate} (default: business date) are marked due; the + * next unposted line is postable once its date has come, and shows as due soon from {@code DUE_WITHIN_DAYS} before. */ public InvestmentInterestScheduleData retrieveInterestSchedule(final Long purchaseId, final LocalDate asOnDate) { final InvestmentPurchase p = find(purchaseId); - final LocalDate asOn = asOnDate == null ? DateUtils.getBusinessLocalDate() : asOnDate; - final BigDecimal unitValue = p.getScript().getFaceValuePerUnit(); - final BigDecimal faceHeld = p.getBalanceUnits().multiply(unitValue); + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate asOn = asOnDate == null ? today : asOnDate; + final BigDecimal faceHeld = faceHeld(p); final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); + final List posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(p.getId()); + final boolean active = InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus()); + final LocalDate reminderUpTo = today.plusDays(InvestmentInterestApiConstants.DUE_WITHIN_DAYS); - final List rows = InvestmentInterestCalculator - .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()) - .toList(); + final List rows = new ArrayList<>(); + boolean nextFound = false; + for (final InvestmentInterestCalculator.ScheduleRow r : scheduleRows(p)) { + final Optional done = postingFor(posted, r); + final boolean isNext = active && done.isEmpty() && !nextFound; + if (isNext) { + nextFound = true; + } + rows.add(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)).posted(done.isPresent()) + .voucherNo(done.map(InvestmentInterestPosting::getVoucherNo).orElse(null)) + .glTransactionId(done.map(InvestmentInterestPosting::getGlTransactionId).orElse(null)) + .postedOnDate(done.map(x -> format(x.getPostedOnDate())).orElse(null)).postable(isNext && !r.date().isAfter(today)) + .dueSoon(active && done.isEmpty() && !r.date().isAfter(reminderUpTo)).build()); + } + final GLAccount incomeGl = this.glAccountRepository + .findOneByGlCodeWithNotFoundDetection(InvestmentInterestApiConstants.interestIncomeGlCode(p.getBondType())); return InvestmentInterestScheduleData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()).certNo(p.getCertNo()) .scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()).faceValue(faceHeld) - .unitValue(unitValue).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()).bookValue(p.getBookValue()) - .intRate(p.getIntRate()).dealDate(format(p.getDealDate())).lastIntDate(format(p.getLastIntDate())) - .maturityDate(format(p.getMaturityDate())).purchasedAccruedInterest(p.getAccruedInterest()) + .unitValue(p.getScript().getFaceValuePerUnit()).balanceUnits(p.getBalanceUnits()).purchaseRate(p.getPurchaseRate()) + .bookValue(p.getBookValue()).intRate(p.getIntRate()).dealDate(format(p.getDealDate())) + .lastIntDate(format(p.getLastIntDate())).maturityDate(format(p.getMaturityDate())) + .purchasedAccruedInterest(p.getAccruedInterest()) .couponPerHalfYear(rate.signum() > 0 ? InvestmentInterestCalculator.halfYearCoupon(faceHeld, rate) : BigDecimal.ZERO) - .asOnDate(format(asOn)).interestGlCode(INTEREST_GL_CODE).interestGlName(INTEREST_GL_NAME).rows(rows).build(); + .asOnDate(format(asOn)).interestGlCode(incomeGl.getGlCode()).interestGlName(incomeGl.getName()) + .bankGlAccountId(p.getCrGlAccount().getId()).bankGlCode(p.getCrGlAccount().getGlCode()) + .bankGlName(p.getCrGlAccount().getName()).status(p.getStatus()).rows(rows).build(); + } + + /** The holding's interest schedule (from its settlement date to maturity) on the face value still held. */ + public List scheduleRows(final InvestmentPurchase p) { + final BigDecimal rate = p.getIntRate() == null ? BigDecimal.ZERO : p.getIntRate(); + return InvestmentInterestCalculator.schedule(faceHeld(p), rate, p.getMaturityDate(), p.getLastIntDate(), p.getSettlementDate()); + } + + /** + * Tasks: for every active holding, its next unposted interest line when that line's date is past or within + * {@code withinDays} days — what has to be posted with a voucher now or shortly. + */ + public List retrieveDueInterest(final int withinDays) { + final LocalDate today = DateUtils.getBusinessLocalDate(); + final LocalDate upTo = today.plusDays(Math.max(withinDays, 0)); + final List due = new ArrayList<>(); + for (final InvestmentPurchase p : this.purchaseRepository.findAll(Sort.by(Sort.Direction.ASC, "id"))) { + if (!InvestmentPurchaseApiConstants.STATUS_ACTIVE.equals(p.getStatus())) { + continue; + } + final List posted = this.postingRepository.findByPurchaseIdOrderByIntDateAsc(p.getId()); + scheduleRows(p).stream().filter(r -> postingFor(posted, r).isEmpty()).findFirst().filter(r -> !r.date().isAfter(upTo)) + .ifPresent(r -> due.add(InvestmentInterestDueData.builder().purchaseId(p.getId()).dealNo(p.getDealNo()) + .certNo(p.getCertNo()).scriptCode(p.getScript().getScriptCode()).scriptDesc(p.getScript().getScriptDesc()) + .lineType(r.type()).intDate(format(r.date())).grossInterest(r.grossInterest()).amount(r.amount()) + .postable(!r.date().isAfter(today)).overdue(r.date().isBefore(today)).build())); + } + return due; + } + + private static Optional postingFor(final List posted, + final InvestmentInterestCalculator.ScheduleRow r) { + return posted.stream().filter(x -> x.getLineType().equals(r.type()) && x.getIntDate().equals(r.date())).findFirst(); + } + + private static BigDecimal faceHeld(final InvestmentPurchase p) { + return p.getBalanceUnits().multiply(p.getScript().getFaceValuePerUnit()); } private InvestmentPurchase find(final Long purchaseId) { 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 796688810..7f440236c 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 @@ -43,26 +43,21 @@ import static org.apache.fineract.portfolio.investment.api.InvestmentPurchaseApi 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; @@ -94,8 +89,7 @@ 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; + private final InvestmentJournalPoster journalPoster; @Override @Transactional @@ -199,31 +193,17 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur + 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); + final InvestmentJournalPoster.Lines lines = new InvestmentJournalPoster.Lines() + .debit(p.getInvestmentGlAccount().getId(), p.getBookValue()) + .debit(this.journalPoster.glId(BROKEN_PERIOD_INTEREST_GL_CODE), p.getAccruedInterest()) + .debit(this.journalPoster.glId(STAMP_DUTY_GL_CODE), p.getStampDuty()) + .debit(this.journalPoster.glId(BROKERAGE_GL_CODE), p.getBrokerage()) + .debit(this.journalPoster.glId(OTHER_CHARGES_GL_CODE), p.getOtherCharges()) + .credit(p.getCrGlAccount().getId(), p.getSettlementAmount()); + final CommandProcessingResult posted = this.journalPoster.post(p.getSettlementDate(), p.getDealNo(), + "Investment purchase settled: deal " + p.getDealNo() + ", " + p.getScript().getScriptCode() + " " + + p.getScript().getScriptDesc() + ", " + p.getParty().getPartyName(), + lines); p.setGlTransactionId(posted.getTransactionId()); p.setSettledOnDate(today); @@ -233,24 +213,6 @@ public class InvestmentPurchaseWritePlatformServiceImpl implements InvestmentPur .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); 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 9961e3adb..43ce7b67f 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 @@ -179,4 +179,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_investment_interest_posting.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_investment_interest_posting.xml new file mode 100644 index 000000000..67331b3ee --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_investment_interest_posting.xml @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SELECT COUNT(*) FROM m_permission WHERE code IN ('READ_INVESTMENTINTEREST', 'CREATE_INVESTMENTINTEREST', 'CREATE_INVESTMENTINTEREST_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 ('READ_INVESTMENTINTEREST', 'CREATE_INVESTMENTINTEREST') + AND NOT EXISTS (SELECT 1 FROM m_role_permission x WHERE x.role_id = rp.role_id AND x.permission_id = p.id) + + +