diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index 830f6f5a2..1bebaca36 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -109,6 +109,7 @@ public class FixedDepositAccountsApiResource { private final FixedDepositAccountInterestCalculationService fixedDepositAccountInterestCalculationService; private final SqlValidator sqlValidator; private final DepositAccountWritePlatformService depositAccountWritePlatformService; + private final org.apache.fineract.portfolio.savings.service.FixedDepositAccountPreviewService fixedDepositAccountPreviewService; @GET @Path("template") @@ -315,6 +316,38 @@ public class FixedDepositAccountsApiResource { return toApiJsonSerializer.serializeResult(result); } + /** + * Fixed deposit quote while the account is being opened, for every FD type: takes the same body as Submit + * (clientId, productId, submittedOnDate, depositAmount, depositPeriod, depositPeriodFrequencyId, isSeniorCitizen, + * locale, dateFormat ...) and returns the rate, maturity date and maturity amount Submit would store, plus the + * interest periods (FD Special: the monthly payout schedule). Nothing is saved (see FixedDepositAccountPreviewService). + */ + @POST + @Path("account-preview") + @Consumes({ MediaType.APPLICATION_JSON }) + @Produces({ MediaType.APPLICATION_JSON }) + public String accountPreview(@Parameter(hidden = true) final String apiRequestBodyAsJson) { + this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME); + final JsonElement parsed = this.fromJsonHelper.parse(apiRequestBodyAsJson); + // Submit insists on a linked savings account - "linkAccountId" (FD Special's monthly payout account) and + // "linkedAccountId" (when the product's Deposit Account Link is on) - which the screen asks for after the quote. + // The quote does not depend on which account it is, only that the interest goes out monthly, so a stand-in id is + // used for whichever is not chosen yet. Both are only recorded on the account, never looked up, and the would-be + // account is never saved, so the stand-in goes nowhere. + if (parsed.isJsonObject()) { + for (final String linkParam : new String[] { DepositsApiConstants.linkedAccountParamName, + org.apache.fineract.portfolio.savings.SavingsApiConstants.linkedAccountIdParamName }) { + if (!parsed.getAsJsonObject().has(linkParam) || parsed.getAsJsonObject().get(linkParam).isJsonNull()) { + parsed.getAsJsonObject().addProperty(linkParam, -1L); + } + } + } + final org.apache.fineract.infrastructure.core.api.JsonCommand command = org.apache.fineract.infrastructure.core.api.JsonCommand + .from(parsed.toString(), parsed, this.fromJsonHelper, DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME, null, + null, null, null, null, null, null, null, null, null, null, null, null); + return this.toApiJsonSerializer.serializeResult(this.fixedDepositAccountPreviewService.preview(command)); + } + private BigDecimal getActivationCharge(Long accountId) { BigDecimal activationCharge = BigDecimal.ZERO; Collection savingCharges = this.savingsAccountChargeReadPlatformService diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java new file mode 100644 index 000000000..947285920 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/FdSpecialProductTermsValidator.java @@ -0,0 +1,119 @@ +/** + * 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.savings.data; + +import java.util.ArrayList; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.core.data.ApiParameterError; +import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException; +import org.apache.fineract.portfolio.savings.SavingsCompoundingInterestPeriodType; +import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType; +import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * FD Special is a non-cumulative product: its interest is paid out to the linked savings account every month, so the + * principal never grows and nothing compounds on it - in effect simple interest on the principal. Its product terms are + * therefore fixed, and the product screen locks them: + * + * Checked here, on the server, against the product as it will be saved, so a request from Postman or any other client + * that sends different values is refused with a validation error rather than saved. Used on create, on update (which + * the maker-checker approval also goes through) and when a maker submits an edit for approval. + */ +@Component +@RequiredArgsConstructor +public class FdSpecialProductTermsValidator { + + private static final String RESOURCE = "fixeddepositproduct"; + private static final String SIMPLE = "Simple"; + + private final JdbcTemplate jdbcTemplate; + + /** The product as assembled or updated, just before it is saved. */ + public void validate(final FixedDepositProduct product) { + validate(product.getFdType(), product.getInterestCalculateSelector(), valueOf(product.interestCompoundingPeriodType()), + valueOf(product.interestPostingPeriodType())); + } + + /** + * A maker's edit, before it waits for approval: each field as the edit leaves it - the submitted value where the + * request sends one, otherwise the product's current value. + */ + public void validateEdit(final FixedDepositProduct product, final JsonCommand command) { + final String fdType = command.parameterExists("fdType") ? command.stringValueOfParameterNamed("fdType") : product.getFdType(); + final Integer selector = command.parameterExists("interestCalculateSelector") + ? command.integerValueOfParameterNamed("interestCalculateSelector") + : product.getInterestCalculateSelector(); + final Integer compounding = command.parameterExists("interestCompoundingPeriodType") + ? command.integerValueOfParameterNamed("interestCompoundingPeriodType") + : valueOf(product.interestCompoundingPeriodType()); + final Integer posting = command.parameterExists("interestPostingPeriodType") + ? command.integerValueOfParameterNamed("interestPostingPeriodType") + : valueOf(product.interestPostingPeriodType()); + validate(fdType, selector, compounding, posting); + } + + private void validate(final String fdType, final Integer interestCalculateSelector, final Integer compoundingPeriodType, + final Integer postingPeriodType) { + if (!"SPECIAL".equalsIgnoreCase(fdType)) { + return; + } + final List errors = new ArrayList<>(); + + final String calculationType = interestCalculateSelector == null ? null + : this.jdbcTemplate.queryForList("SELECT code_value FROM m_code_value WHERE id = ?", String.class, interestCalculateSelector) + .stream().findFirst().orElse(null); + if (calculationType == null || !SIMPLE.equalsIgnoreCase(calculationType.trim())) { + errors.add(ApiParameterError.parameterError("validation.msg." + RESOURCE + ".special.interestCalculateSelector.must.be.simple", + "FD Special pays its interest out every month, so nothing compounds: Interest Calculation Type must be Simple" + + (calculationType == null ? "." : ", not " + calculationType + "."), + "interestCalculateSelector", interestCalculateSelector)); + } + if (compoundingPeriodType == null || compoundingPeriodType != SavingsCompoundingInterestPeriodType.MONTHLY.getValue()) { + errors.add(ApiParameterError.parameterError( + "validation.msg." + RESOURCE + ".special.interestCompoundingPeriodType.must.be.monthly", + "FD Special pays its interest out every month: Interest Compounding Period must be Monthly.", + "interestCompoundingPeriodType", compoundingPeriodType)); + } + if (postingPeriodType == null || postingPeriodType != SavingsPostingInterestPeriodType.MONTHLY.getValue()) { + errors.add(ApiParameterError.parameterError( + "validation.msg." + RESOURCE + ".special.interestPostingPeriodType.must.be.monthly", + "FD Special pays its interest out every month: Interest Posting Period must be Monthly.", "interestPostingPeriodType", + postingPeriodType)); + } + if (!errors.isEmpty()) { + throw new PlatformApiDataValidationException("validation.msg.validation.errors.exist", "Validation errors exist.", errors); + } + } + + private static Integer valueOf(final SavingsCompoundingInterestPeriodType type) { + return type == null ? null : type.getValue(); + } + + private static Integer valueOf(final SavingsPostingInterestPeriodType type) { + return type == null ? null : type.getValue(); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java new file mode 100644 index 000000000..df7de9842 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountPreviewService.java @@ -0,0 +1,148 @@ +/** + * 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.savings.service; + +import java.math.BigDecimal; +import java.math.MathContext; +import java.math.RoundingMode; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.apache.fineract.infrastructure.configuration.domain.ConfigurationDomainService; +import org.apache.fineract.infrastructure.core.api.JsonCommand; +import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext; +import org.apache.fineract.organisation.monetary.domain.MoneyHelper; +import org.apache.fineract.portfolio.savings.DepositAccountType; +import org.apache.fineract.portfolio.savings.domain.DepositAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount; +import org.apache.fineract.portfolio.savings.domain.SavingsAccountAssembler; +import org.apache.fineract.portfolio.savings.domain.interest.PostingPeriod; +import org.springframework.stereotype.Service; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +/** + * Fixed deposit quote for the account-opening screen - every FD type - worked out by the account engine itself rather + * than by the screen or a separate calculator. + *

+ * The would-be account is built exactly as Submit builds it ({@code submitFDApplication}): the same assembler (product + * defaults, the product's interest rate chart, the CBS base-rate card and senior-citizen rate), the same maturity + * calculation, the same CBS fields. Nothing is saved: it all runs in a transaction that is always rolled back. So the + * rate, maturity date and maturity amount quoted are exactly what Submit would store - including the product's posting + * periods (e.g. Quarterly posting closes each period at the calendar quarter end, from the financial-year start month), + * which a stand-alone formula that counts quarters from the deposit date gets wrong. + *

+ * The interest periods come from {@link FixedDepositAccount#projectInterestSchedule}, the method the account's Monthly + * Interest Payout Schedule uses once it is open. For FD Special they are the monthly payouts; for a cumulative FD they + * are the periods interest is credited to the deposit and compounds. + *

+ * The schedule assumes the deposit starts on the submitted date; once opened, the account's own schedule runs from its + * actual activation date. + */ +@Service +@RequiredArgsConstructor +public class FixedDepositAccountPreviewService { + + private final PlatformSecurityContext context; + private final DepositAccountAssembler depositAccountAssembler; + private final SavingsAccountAssembler savingsAccountAssembler; + private final ConfigurationDomainService configurationDomainService; + private final PlatformTransactionManager transactionManager; + + public Map preview(final JsonCommand command) { + final TransactionTemplate template = new TransactionTemplate(this.transactionManager); + return template.execute(status -> { + // Never keep anything the assembler touched: this is a quote, not an application. + status.setRollbackOnly(); + return buildPreview(command); + }); + } + + private Map buildPreview(final JsonCommand command) { + final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService + .isSavingsInterestPostingAtCurrentPeriodEnd(); + final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth(); + + // Same steps, in the same order, as DepositApplicationProcessWritePlatformServiceJpaRepositoryImpl#submitFDApplication. + final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(command, + this.context.authenticatedUser(), DepositAccountType.FIXED_DEPOSIT); + final boolean special = "SPECIAL".equalsIgnoreCase(account.fdType()); + account.updateMaturityDateAndAmountBeforeAccountActivation(MathContext.DECIMAL64, false, + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + this.savingsAccountAssembler.applyCbsFields(command, command.parsedJson(), account); + + // Same MathContext and clipping as DepositAccountWritePlatformServiceJpaRepositoryImpl#retrieveProjectedInterestSchedule. + final RoundingMode rounding = MoneyHelper.getRoundingMode(); + final List postingPeriods = account.projectInterestSchedule(new MathContext(15, rounding), + isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth); + final LocalDate maturityDate = account.maturityDate(); + final LocalDate lastCalculableDate = maturityDate != null ? maturityDate.minusDays(1) : null; + + final List> periods = new ArrayList<>(); + BigDecimal totalInterest = BigDecimal.ZERO; + int number = 1; + for (final PostingPeriod period : postingPeriods) { + final LocalDate fromDate = period.getPeriodInterval().startDate(); + LocalDate toDate = period.getPeriodInterval().endDate(); + if (lastCalculableDate != null && toDate.isAfter(lastCalculableDate)) { + toDate = lastCalculableDate; + } + final BigDecimal interest = period.getInterestEarned().getAmount(); + totalInterest = totalInterest.add(interest); + final Map row = new LinkedHashMap<>(); + row.put("period", number++); + row.put("fromDate", fromDate.toString()); + row.put("toDate", toDate.toString()); + row.put("days", ChronoUnit.DAYS.between(fromDate, toDate) + 1); + row.put("interestAmount", interest); + periods.add(row); + } + + final BigDecimal depositAmount = account.getDepositAmount(); + final BigDecimal rate = account.getNominalAnnualInterestRate(); + final Map result = new LinkedHashMap<>(); + result.put("fdType", account.fdType()); + result.put("depositAmount", depositAmount); + result.put("annualInterestRate", rate); + result.put("seniorCitizen", account.isSeniorCitizen()); + result.put("interestPostingPeriodType", account.getInterestPostingPeriodType()); + result.put("interestCalculationDaysInYearType", account.getInterestCalculationDaysInYearType()); + result.put("startDate", periods.isEmpty() ? null : periods.get(0).get("fromDate")); + result.put("maturityDate", maturityDate == null ? null : maturityDate.toString()); + result.put("numberOfPayouts", special ? periods.size() : 0); + // FD Special: one full month at the applied rate; a period pays for the days it actually covers (see periods). + result.put("fullMonthPayout", !special || rate == null ? null + : depositAmount.multiply(rate, MoneyHelper.getMathContext()).divide(BigDecimal.valueOf(1200), MoneyHelper.getMathContext()) + .setScale(2, rounding)); + // The account's own maturity amount, as Submit stores it: for FD Special the principal alone (the interest is paid + // out month by month), otherwise principal plus the compounded interest. + final BigDecimal maturityAmount = account.maturityAmount(); + final BigDecimal interest = special ? totalInterest + : (maturityAmount == null ? null : maturityAmount.subtract(depositAmount)); + result.put("totalInterest", interest); + result.put("maturityAmount", maturityAmount); + result.put("totalValueAtMaturity", interest == null ? null : depositAmount.add(interest)); + result.put("periods", periods); + return result; + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java index 053998bf9..c73789f6d 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositProductWritePlatformServiceJpaRepositoryImpl.java @@ -89,6 +89,7 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements private final ProductPendingEditGlMappingRepository pendingEditGlMappingRepository; private final FromJsonHelper fromApiJsonHelper; private final CommandSourceRepository commandSourceRepository; + private final org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator; @Transactional @Override @@ -98,6 +99,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements this.fromApiJsonDataValidator.validateForFixedDepositCreate(command.json()); final FixedDepositProduct product = this.depositProductAssembler.assembleFixedDepositProduct(command); + // FD Special: Simple interest, monthly compounding and posting - refused here whatever client sent it. + this.fdSpecialProductTermsValidator.validate(product); this.fixedDepositProductRepository.saveAndFlush(product); @@ -136,6 +139,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements product.setHelpers(this.chartAssembler); final Map changes = product.update(command); + // The product as this update leaves it (also the path an approved maker-checker edit takes). + this.fdSpecialProductTermsValidator.validate(product); if (changes.containsKey(chargesParamName)) { final Set savingsProductCharges = this.depositProductAssembler.assembleListOfSavingsProductCharges(command, @@ -197,6 +202,8 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements } this.fromApiJsonDataValidator.validateForFixedDepositUpdate(command.json()); + // Refused before it waits for a checker, not only when the checker approves it. + this.fdSpecialProductTermsValidator.validateEdit(product, command); final ProductPendingEdit pendingEdit = new ProductPendingEdit(ProductPendingEdit.TYPE_FD, productId, maker); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 5b012228b..b1f5f99a4 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -366,11 +366,12 @@ public class SavingsConfiguration { ProductPendingEditChargeRepository pendingEditChargeRepository, ProductPendingEditGlMappingRepository pendingEditGlMappingRepository, FromJsonHelper fromApiJsonHelper, - CommandSourceRepository commandSourceRepository) { + CommandSourceRepository commandSourceRepository, + org.apache.fineract.portfolio.savings.data.FdSpecialProductTermsValidator fdSpecialProductTermsValidator) { return new FixedDepositProductWritePlatformServiceJpaRepositoryImpl(context, fixedDepositProductRepository, fromApiJsonDataValidator, depositProductAssembler, accountMappingWritePlatformService, chartAssembler, productPendingEditRepository, pendingEditSubmittedFieldRepository, pendingEditChargeRepository, - pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository); + pendingEditGlMappingRepository, fromApiJsonHelper, commandSourceRepository, fdSpecialProductTermsValidator); } @Bean