feat(accounts): backend fixes for Modify Application feature (Current/Savings/FD/RD)

Current Account:
- CurrentAccountReadPlatformServiceImpl: add retrieveJointHolders() and retrievePartners()
- CurrentAccountWritePlatformServiceImpl: full modifyApplication() - updates all CBS fields,
  joint holders, partners, nominees, facility config, dates, introducer
- Fix cbsAccountType save: parse string account type from 'accountType' JSON field

Nominees:
- ClientNomineeWritePlatformService: add replaceNomineesForAccount() for atomic replace
- Savings/FD/RD modifyApplication: replace nominees on modify

FD/RD Read (DepositAccountReadPlatformServiceImpl):
- Add isSeniorCitizen, seniorCitizenInterestRate to BASE SQL SELECT
- Add setCbsAccountFields() call in FD/RD mappers reading accountStatus, modeOfOperation,
  introducerClientId, guardianFields, linkedAccountId, isSeniorCitizen from ResultSet
- Fix retrieveOne() to also fetch joint holders from m_savings_account_joint_holders
- Use explicit LinkedHashMap in joint holder query (camelCase keys for Angular)

FD/RD Data Classes:
- FixedDepositAccountData: add accountStatus, modeOfOperation, introducerClientId,
  linkedAccountId, jointHolders, isSeniorCitizen, seniorCitizenInterestRate fields
- RecurringDepositAccountData: same fields added
- Fix all factory methods (withInterestChartAndRecurringDetails, withClosureTemplateDetails,
  withTemplateOptions, associationsAndTemplate) to call copyCbsFieldsFrom()
- Fix SavingsAccountReadPlatformServiceImpl joint holders: use camelCase keys

FD Calculator:
- FixedDepositAccountInterestCalculationServiceImpl: add calculateExact() with actual
  calendar-based calculation matching PostingPeriod engine (exact same as account creation)
- Add submittedOnDate, daysInYear, interestPostingPeriodInMonths optional params
- Support interestCompoundingPeriodInMonths=0 (daily compounding) in both validator and calc
- DepositsApiConstants: add submittedOnDate/dateFormat/locale/daysInYear to allowed params
- DepositAccountDataValidator: allow 0 (daily) in interestCompoundingPeriodInMonths

Misc:
- SavingsProductData: null-check in isCashBased/isAccrualBased methods (NPE fix)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-03 10:32:59 +05:30
co-authored by Claude Sonnet 4.6
parent 7c4cf6260c
commit a051faba5d
19 changed files with 68620 additions and 45192 deletions
@@ -512,6 +512,11 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
fixedDepositInterestCalculationData.add("submittedOnDate");
fixedDepositInterestCalculationData.add("dateFormat");
fixedDepositInterestCalculationData.add("locale");
fixedDepositInterestCalculationData.add("daysInYear");
return fixedDepositInterestCalculationData;
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -1,41 +1,47 @@
/**
* 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.client.service;
import com.google.gson.JsonElement;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface ClientNomineeWritePlatformService {
CommandProcessingResult addNominee(Long clientId, JsonCommand command);
CommandProcessingResult updateNominee(Long clientId, Long nomineeId, JsonCommand command);
CommandProcessingResult deleteNominee(Long clientId, Long nomineeId);
/**
* Saves nominee from a JSON element already extracted from the account creation payload.
* Called internally during account creation (SB/CB/FD/RD) so nominee is persisted
* in the same transaction without a separate API call.
*/
void saveNomineeIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineeJson);
void saveNomineesIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineesJson);
}
/**
* 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.client.service;
import com.google.gson.JsonElement;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface ClientNomineeWritePlatformService {
CommandProcessingResult addNominee(Long clientId, JsonCommand command);
CommandProcessingResult updateNominee(Long clientId, Long nomineeId, JsonCommand command);
CommandProcessingResult deleteNominee(Long clientId, Long nomineeId);
/**
* Saves nominee from a JSON element already extracted from the account creation payload.
* Called internally during account creation (SB/CB/FD/RD) so nominee is persisted
* in the same transaction without a separate API call.
*/
void saveNomineeIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineeJson);
void saveNomineesIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineesJson);
/**
* Soft-deletes all existing nominees for the given account, then saves the new nominees.
* Used during modifyApplication to replace nominees atomically.
*/
void replaceNomineesForAccount(Long clientId, String accountNo, String accountType, JsonElement nomineesJson);
}
@@ -465,4 +465,20 @@ public class ClientNomineeWritePlatformServiceImpl implements ClientNomineeWrite
saveNomineeIfPresent(clientId, accountNo, accountType, nomineesJson);
}
}
@Override
public void replaceNomineesForAccount(final Long clientId, final String accountNo, final String accountType, final JsonElement nomineesJson) {
// Soft-delete all existing nominees for this account
final List<org.apache.fineract.portfolio.client.domain.ClientNominee> existing =
nomineeRepository.findByClientIdAndAccountNoAndAccountType(clientId, accountNo, accountType);
for (org.apache.fineract.portfolio.client.domain.ClientNominee n : existing) {
n.setIsDeleted(true);
n.setIsActive(false);
}
nomineeRepository.saveAll(existing);
// Save new nominees
if (nomineesJson != null && !nomineesJson.isJsonNull()) {
saveNomineesIfPresent(clientId, accountNo, accountType, nomineesJson);
}
}
}
@@ -257,24 +257,34 @@ public class FixedDepositAccountsApiResource {
@Produces({ MediaType.APPLICATION_JSON })
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FixedDepositAccountsApiResourceSwagger.CalculateFixedDepositInterestResponse.class)))
public String calculateFixedDepositInterest(@Context final UriInfo uriInfo,
@QueryParam("principalAmount") @Parameter(description = "BigDecimal principalAmount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "annualInterestRate") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "tenureInMonths") final Long tenureInMonths,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "interestCompoundingPeriodInMonths") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "interestPostingPeriodInMonths") final Long interestPostingPeriodInMonths) {
@QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "Posting period in months (1,3,6,12)") final Long interestPostingPeriodInMonths,
@QueryParam("submittedOnDate") @Parameter(description = "Submission date (yyyy-MM-dd) for exact calendar calculation") final String submittedOnDate,
@QueryParam("daysInYear") @Parameter(description = "Days in year (360 or 365)") final Integer daysInYear) {
HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths);
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount);
// Pass exact calculation params if provided
if (submittedOnDate != null && !submittedOnDate.isBlank()) {
request.put("submittedOnDate", submittedOnDate); // pass as string, extracted in service
request.put("dateFormat", "yyyy-MM-dd");
request.put("locale", "en");
}
if (daysInYear != null) {
request.put("daysInYear", daysInYear);
}
String apiRequestBodyAsJson = toApiJsonSerializer.serialize(request);
JsonElement jsonElement = fromJsonHelper.parse(apiRequestBodyAsJson);
HashMap result = fixedDepositAccountInterestCalculationService
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, jsonElement, fromJsonHelper));
return toApiJsonSerializer.serializeResult(result);
}
private BigDecimal getActivationCharge(Long accountId) {
@@ -66,11 +66,53 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
throw new CurrentAccountNotFoundException(accountId);
}
final Map<String, Object> response = buildStructuredResponse(rows.get(0));
response.put("jointHolders", retrieveJointHolders(accountId));
response.put("partners", retrievePartners(accountId));
response.put("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId));
return response;
}
private List<Map<String, Object>> retrieveJointHolders(Long accountId) {
final String sql =
"SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
+ "FROM m_current_account_joint_holder jh "
+ "WHERE jh.current_account_id = ? "
+ "ORDER BY jh.is_primary DESC, jh.id ASC";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> h = new LinkedHashMap<>();
h.put("id", rs.getLong("id"));
h.put("clientId", rs.getLong("client_id"));
h.put("cifNumber", rs.getString("cif_number"));
h.put("displayName", rs.getString("display_name"));
h.put("sharePercentage", rs.getBigDecimal("share_percentage"));
h.put("signingAuthority", rs.getString("signing_authority"));
h.put("role", rs.getString("role"));
h.put("isPrimary", rs.getBoolean("is_primary"));
h.put("isMandatory", rs.getBoolean("is_mandatory"));
return h;
}, accountId);
}
private List<Map<String, Object>> retrievePartners(Long accountId) {
final String sql =
"SELECT p.id, p.client_id, p.client_cif, p.client_name, p.share_percentage, p.is_mandatory "
+ "FROM m_current_account_partner p "
+ "WHERE p.current_account_id = ? "
+ "ORDER BY p.id ASC";
return jdbcTemplate.query(sql, (rs, rowNum) -> {
final Map<String, Object> p = new LinkedHashMap<>();
p.put("id", rs.getLong("id"));
p.put("clientId", rs.getLong("client_id"));
p.put("clientCif", rs.getString("client_cif"));
p.put("clientName", rs.getString("client_name"));
p.put("sharePercentage", rs.getBigDecimal("share_percentage"));
p.put("isMandatory", rs.getBoolean("is_mandatory"));
return p;
}, accountId);
}
private List<Map<String, Object>> retrieveTransactions(Long accountId) {
final String sql =
"SELECT t.id, t.transaction_type_enum, t.transaction_date, t.amount, "
@@ -309,13 +309,44 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
sqlBuilder.append(depositAccountMapper.schema());
sqlBuilder.append(" where sa.id = ? and sa.deposit_type_enum = ? ");
return this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue());
final DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId, depositAccountType.getValue());
if (account != null) {
final java.util.List<java.util.Map<String, Object>> jointHolders = retrieveJointHolders(accountId);
if (!jointHolders.isEmpty()) {
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account).setJointHolders(jointHolders);
} else if (account instanceof org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) {
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
}
}
}
return account;
} catch (final EmptyResultDataAccessException e) {
throw new DepositAccountNotFoundException(depositAccountType, accountId, e);
}
}
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
+ "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? "
+ "ORDER BY jh.is_primary DESC, jh.id ASC";
return this.jdbcTemplate.query(sql, (rs, rowNum) -> {
final java.util.Map<String, Object> h = new java.util.LinkedHashMap<>();
h.put("id", rs.getLong("id"));
h.put("clientId", rs.getLong("client_id"));
h.put("cifNumber", rs.getString("cif_number"));
h.put("displayName", rs.getString("display_name"));
h.put("sharePercentage", rs.getBigDecimal("share_percentage"));
h.put("signingAuthority", rs.getString("signing_authority"));
h.put("role", rs.getString("role"));
h.put("isPrimary", rs.getBoolean("is_primary"));
h.put("isMandatory", rs.getBoolean("is_mandatory"));
return h;
}, accountId);
}
@Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
try {
@@ -812,6 +843,8 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
selectFieldsSqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, ");
selectFieldsSqlBuilder.append("sa.linked_account_id as linkedAccountId, sa.linked_account_type as linkedAccountType, ");
selectFieldsSqlBuilder.append("sa.linked_account_no as linkedAccountNo, ");
selectFieldsSqlBuilder.append("COALESCE(sa.is_senior_citizen, false) as isSeniorCitizen, ");
selectFieldsSqlBuilder.append("sa.senior_citizen_interest_rate as seniorCitizenInterestRate, ");
selectFieldsSqlBuilder.append("sa.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
selectFieldsSqlBuilder.append("sa.withhold_tax as withHoldTax,");
selectFieldsSqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
@@ -1111,6 +1144,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"),
rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"),
rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate"));
result.setCbsAccountFields(rs.getString("accountStatus"), rs.getString("modeOfOperation"),
JdbcSupport.getLong(rs, "introducerClientId"), rs.getString("introducerName"),
rs.getString("introducerCifNumber"), rs.getString("specialCategory"), rs.getString("weakerSection"),
rs.getString("guardianName"), rs.getString("guardianCifNumber"), rs.getString("guardianRelationship"),
JdbcSupport.getLong(rs, "guardianClientId"), JdbcSupport.getLocalDate(rs, "minorDateOfBirth"),
JdbcSupport.getLong(rs, "linkedAccountId"),
rs.getBoolean("isSeniorCitizen"), JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "seniorCitizenInterestRate"));
return result;
}
}
@@ -1268,6 +1308,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"),
rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"),
rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate"));
result.setCbsAccountFields(rs.getString("accountStatus"), rs.getString("modeOfOperation"),
JdbcSupport.getLong(rs, "introducerClientId"), rs.getString("introducerName"),
rs.getString("introducerCifNumber"), rs.getString("specialCategory"), rs.getString("weakerSection"),
rs.getString("guardianName"), rs.getString("guardianCifNumber"), rs.getString("guardianRelationship"),
JdbcSupport.getLong(rs, "guardianClientId"), JdbcSupport.getLocalDate(rs, "minorDateOfBirth"),
JdbcSupport.getLong(rs, "linkedAccountId"),
rs.getBoolean("isSeniorCitizen"), JdbcSupport.getBigDecimalDefaultToNullIfZero(rs, "seniorCitizenInterestRate"));
return result;
@@ -1,74 +1,147 @@
/**
* 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 static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.math.MathContext;
import java.util.HashMap;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonQuery;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FixedDepositAccountInterestCalculationServiceImpl implements FixedDepositAccountInterestCalculationService {
private final DepositAccountDataValidator depositAccountDataValidator;
private final FromJsonHelper fromApiJsonHelper;
@Override
public HashMap calculateInterest(JsonQuery query) {
depositAccountDataValidator.validateFixedDepositForInterestCalculation(query.json());
JsonElement element = query.parsedJson();
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName,
element);
BigDecimal maturityAmount = this.calculateInterestInternal(principalAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths);
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
HashMap result = new HashMap<>();
result.put("maturityAmount", maturityAmount);
result.put("warning", warning);
return result;
}
public BigDecimal calculateInterestInternal(BigDecimal principalAmount, BigDecimal annualInterestRate, Long tenureInMonths,
Long interestCompoundingPeriodInMonths) {
BigDecimal numberOfCompoundingsPerAnnum = BigDecimal.valueOf(12).divide(BigDecimal.valueOf(interestCompoundingPeriodInMonths));
Long totalNumberOfCompoundings = tenureInMonths / interestCompoundingPeriodInMonths;
MathContext mc = MoneyHelper.getMathContext();
BigDecimal exponentialTerm = annualInterestRate.divide(numberOfCompoundingsPerAnnum, mc).divide(BigDecimal.valueOf(100), mc)
.add(BigDecimal.valueOf(1)).pow(Math.toIntExact(totalNumberOfCompoundings));
return principalAmount.multiply(exponentialTerm);
}
}
/**
* 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 static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement;
import java.math.BigDecimal;
import java.math.MathContext;
import java.math.RoundingMode;
import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.HashMap;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.api.JsonQuery;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class FixedDepositAccountInterestCalculationServiceImpl implements FixedDepositAccountInterestCalculationService {
private final DepositAccountDataValidator depositAccountDataValidator;
private final FromJsonHelper fromApiJsonHelper;
@Override
public HashMap calculateInterest(JsonQuery query) {
depositAccountDataValidator.validateFixedDepositForInterestCalculation(query.json());
JsonElement element = query.parsedJson();
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
// Optional params for exact calendar-based calculation
LocalDate submittedOnDate = null;
try {
final String dateStr = this.fromApiJsonHelper.extractStringNamed("submittedOnDate", element);
final String dateFormat = this.fromApiJsonHelper.extractStringNamed("dateFormat", element);
if (dateStr != null && dateFormat != null) {
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
}
} catch (Exception ignored) {}
Integer daysInYear = null;
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
if (daysInYearLong != null) daysInYear = daysInYearLong.intValue();
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed("interestPostingPeriodInMonths", element);
BigDecimal maturityAmount;
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
// Exact calendar-based calculation — same algorithm as account creation
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths);
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
} else {
maturityAmount = this.calculateInterestInternal(principalAmount, annualInterestRate, tenureInMonths,
interestCompoundingPeriodInMonths);
}
HashMap result = new HashMap<>();
result.put("maturityAmount", maturityAmount);
result.put("totalInterest", maturityAmount.subtract(principalAmount).setScale(2, RoundingMode.HALF_UP));
return result;
}
/**
* Exact calendar-based FD maturity calculation — replicates the PostingPeriod engine
* used during actual account creation/activation. Uses actual days per month.
*/
public BigDecimal calculateExact(final BigDecimal principal, final BigDecimal annualRate,
final LocalDate submittedOnDate, final LocalDate maturityDate,
final long compoundingMonths, final long postingMonths, final int daysInYear) {
final MathContext mc = MoneyHelper.getMathContext();
BigDecimal balance = principal;
LocalDate periodStart = submittedOnDate;
while (periodStart.isBefore(maturityDate)) {
// Determine end of this posting period (based on actual calendar)
LocalDate periodEnd;
if (postingMonths == 0) {
periodEnd = periodStart.plusDays(1);
} else {
periodEnd = periodStart.plusMonths(postingMonths);
}
if (periodEnd.isAfter(maturityDate)) {
periodEnd = maturityDate;
}
final long daysInPeriod = ChronoUnit.DAYS.between(periodStart, periodEnd);
if (compoundingMonths == 0) {
// Daily compounding — exact: balance × (1 + r/D)^days
final double dailyRate = annualRate.doubleValue() / (daysInYear * 100.0);
final double multiplier = Math.pow(1.0 + dailyRate, daysInPeriod);
balance = balance.multiply(BigDecimal.valueOf(multiplier), mc);
} else {
// Periodic compounding — interest = balance × rate × days / (daysInYear × 100)
final BigDecimal interest = balance
.multiply(annualRate, mc)
.multiply(BigDecimal.valueOf(daysInPeriod), mc)
.divide(BigDecimal.valueOf((long) daysInYear * 100), mc);
balance = balance.add(interest);
}
periodStart = periodEnd;
}
return balance.setScale(2, RoundingMode.HALF_UP);
}
public BigDecimal calculateInterestInternal(BigDecimal principalAmount, BigDecimal annualInterestRate, Long tenureInMonths,
Long interestCompoundingPeriodInMonths) {
if (interestCompoundingPeriodInMonths == null || interestCompoundingPeriodInMonths <= 0) {
// Daily compounding fallback: approximate with monthly
interestCompoundingPeriodInMonths = 1L;
}
BigDecimal numberOfCompoundingsPerAnnum = BigDecimal.valueOf(12).divide(BigDecimal.valueOf(interestCompoundingPeriodInMonths));
Long totalNumberOfCompoundings = tenureInMonths / interestCompoundingPeriodInMonths;
MathContext mc = MoneyHelper.getMathContext();
BigDecimal exponentialTerm = annualInterestRate.divide(numberOfCompoundingsPerAnnum, mc).divide(BigDecimal.valueOf(100), mc)
.add(BigDecimal.valueOf(1)).pow(Math.toIntExact(totalNumberOfCompoundings));
return principalAmount.multiply(exponentialTerm);
}
}
@@ -267,13 +267,25 @@ public class SavingsAccountReadPlatformServiceImpl implements SavingsAccountRead
final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper,
new Object[] { accountId }); // NOSONAR
// Load joint holders
// Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL)
if (accountData != null) {
final String jointSql = "SELECT jh.id as id, jh.client_id as clientId, jh.cif_number as cifNumber, "
+ "jh.display_name as displayName, jh.share_percentage as sharePercentage, "
+ "jh.signing_authority as signingAuthority, jh.role as role, jh.is_primary as isPrimary "
+ "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ?";
final List<java.util.Map<String, Object>> jointHolders = this.jdbcTemplate.queryForList(jointSql, accountId);
final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
+ "FROM m_savings_account_joint_holders jh WHERE jh.savings_account_id = ? "
+ "ORDER BY jh.is_primary DESC, jh.id ASC";
final List<java.util.Map<String, Object>> jointHolders = this.jdbcTemplate.query(jointSql, (rs, rowNum) -> {
final java.util.Map<String, Object> h = new java.util.LinkedHashMap<>();
h.put("id", rs.getLong("id"));
h.put("clientId", rs.getLong("client_id"));
h.put("cifNumber", rs.getString("cif_number"));
h.put("displayName", rs.getString("display_name"));
h.put("sharePercentage", rs.getBigDecimal("share_percentage"));
h.put("signingAuthority", rs.getString("signing_authority"));
h.put("role", rs.getString("role"));
h.put("isPrimary", rs.getBoolean("is_primary"));
h.put("isMandatory", rs.getBoolean("is_mandatory"));
return h;
}, accountId);
if (!jointHolders.isEmpty()) {
accountData.setJointHolders(jointHolders);
}