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
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);
}