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(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName); fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName); fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
fixedDepositInterestCalculationData.add("submittedOnDate");
fixedDepositInterestCalculationData.add("dateFormat");
fixedDepositInterestCalculationData.add("locale");
fixedDepositInterestCalculationData.add("daysInYear");
return fixedDepositInterestCalculationData; 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 * Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file * or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information * distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file * regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the * to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance * "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at * with the License. You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, * Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an * software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the * KIND, either express or implied. See the License for the
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
package org.apache.fineract.portfolio.client.service; package org.apache.fineract.portfolio.client.service;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import org.apache.fineract.infrastructure.core.api.JsonCommand; import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult; import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
public interface ClientNomineeWritePlatformService { public interface ClientNomineeWritePlatformService {
CommandProcessingResult addNominee(Long clientId, JsonCommand command); CommandProcessingResult addNominee(Long clientId, JsonCommand command);
CommandProcessingResult updateNominee(Long clientId, Long nomineeId, JsonCommand command); CommandProcessingResult updateNominee(Long clientId, Long nomineeId, JsonCommand command);
CommandProcessingResult deleteNominee(Long clientId, Long nomineeId); CommandProcessingResult deleteNominee(Long clientId, Long nomineeId);
/** /**
* Saves nominee from a JSON element already extracted from the account creation payload. * 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 * Called internally during account creation (SB/CB/FD/RD) so nominee is persisted
* in the same transaction without a separate API call. * in the same transaction without a separate API call.
*/ */
void saveNomineeIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineeJson); void saveNomineeIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineeJson);
void saveNomineesIfPresent(Long clientId, String accountNo, String accountType, JsonElement nomineesJson); 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); 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 }) @Produces({ MediaType.APPLICATION_JSON })
@ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FixedDepositAccountsApiResourceSwagger.CalculateFixedDepositInterestResponse.class))) @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(implementation = FixedDepositAccountsApiResourceSwagger.CalculateFixedDepositInterestResponse.class)))
public String calculateFixedDepositInterest(@Context final UriInfo uriInfo, public String calculateFixedDepositInterest(@Context final UriInfo uriInfo,
@QueryParam("principalAmount") @Parameter(description = "BigDecimal principalAmount") final BigDecimal principalAmount, @QueryParam("principalAmount") @Parameter(description = "Deposit principal amount") final BigDecimal principalAmount,
@QueryParam("annualInterestRate") @Parameter(description = "annualInterestRate") final BigDecimal annualInterestRate, @QueryParam("annualInterestRate") @Parameter(description = "Annual interest rate (%)") final BigDecimal annualInterestRate,
@QueryParam("tenureInMonths") @Parameter(description = "tenureInMonths") final Long tenureInMonths, @QueryParam("tenureInMonths") @Parameter(description = "Tenure in months") final Long tenureInMonths,
@QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "interestCompoundingPeriodInMonths") final Long interestCompoundingPeriodInMonths, @QueryParam("interestCompoundingPeriodInMonths") @Parameter(description = "Compounding period in months (1,3,6,12)") final Long interestCompoundingPeriodInMonths,
@QueryParam("interestPostingPeriodInMonths") @Parameter(description = "interestPostingPeriodInMonths") final Long interestPostingPeriodInMonths) { @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<>(); HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate); request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths); request.put("tenureInMonths", tenureInMonths);
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths); request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths); request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount); 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); String apiRequestBodyAsJson = toApiJsonSerializer.serialize(request);
JsonElement jsonElement = fromJsonHelper.parse(apiRequestBodyAsJson); JsonElement jsonElement = fromJsonHelper.parse(apiRequestBodyAsJson);
HashMap result = fixedDepositAccountInterestCalculationService HashMap result = fixedDepositAccountInterestCalculationService
.calculateInterest(new JsonQuery(apiRequestBodyAsJson, jsonElement, fromJsonHelper)); .calculateInterest(new JsonQuery(apiRequestBodyAsJson, jsonElement, fromJsonHelper));
return toApiJsonSerializer.serializeResult(result); return toApiJsonSerializer.serializeResult(result);
} }
private BigDecimal getActivationCharge(Long accountId) { private BigDecimal getActivationCharge(Long accountId) {
@@ -66,11 +66,53 @@ public class CurrentAccountReadPlatformServiceImpl implements CurrentAccountRead
throw new CurrentAccountNotFoundException(accountId); throw new CurrentAccountNotFoundException(accountId);
} }
final Map<String, Object> response = buildStructuredResponse(rows.get(0)); 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("charges", retrieveCharges(accountId));
response.put("transactions", retrieveTransactions(accountId)); response.put("transactions", retrieveTransactions(accountId));
return response; 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) { private List<Map<String, Object>> retrieveTransactions(Long accountId) {
final String sql = final String sql =
"SELECT t.id, t.transaction_type_enum, t.transaction_date, t.amount, " "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(depositAccountMapper.schema());
sqlBuilder.append(" where sa.id = ? and sa.deposit_type_enum = ? "); 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) { } catch (final EmptyResultDataAccessException e) {
throw new DepositAccountNotFoundException(depositAccountType, accountId, 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 @Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) { public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
try { try {
@@ -812,6 +843,8 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
selectFieldsSqlBuilder.append("sa.introducer_cif_number as introducerCifNumber, "); 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_id as linkedAccountId, sa.linked_account_type as linkedAccountType, ");
selectFieldsSqlBuilder.append("sa.linked_account_no as linkedAccountNo, "); 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.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
selectFieldsSqlBuilder.append("sa.withhold_tax as withHoldTax,"); selectFieldsSqlBuilder.append("sa.withhold_tax as withHoldTax,");
selectFieldsSqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName "); 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("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"),
rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"), rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"),
rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate")); 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; return result;
} }
} }
@@ -1268,6 +1308,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"), rs.getBoolean("creditMaturityProceeds"), rs.getBoolean("penaltyGracePeriodMissedInstallments"),
rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"), rs.getBoolean("panelRequestApplicable"), rs.getBoolean("noPenaltyNomineeHeirClosure"),
rs.getBoolean("deceasedSettlement"), rs.getBoolean("allowDebitTxnAfterDueDate")); 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; return result;
@@ -1,74 +1,147 @@
/** /**
* Licensed to the Apache Software Foundation (ASF) under one * Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file * or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information * distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file * regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the * to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance * "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at * with the License. You may obtain a copy of the License at
* *
* http://www.apache.org/licenses/LICENSE-2.0 * http://www.apache.org/licenses/LICENSE-2.0
* *
* Unless required by applicable law or agreed to in writing, * Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an * software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the * KIND, either express or implied. See the License for the
* specific language governing permissions and limitations * specific language governing permissions and limitations
* under the License. * under the License.
*/ */
package org.apache.fineract.portfolio.savings.service; package org.apache.fineract.portfolio.savings.service;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.annualInterestRateParamName; 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.interestCompoundingPeriodInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName; import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement; import com.google.gson.JsonElement;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.math.MathContext; import java.math.MathContext;
import java.util.HashMap; import java.math.RoundingMode;
import lombok.RequiredArgsConstructor; import java.time.LocalDate;
import org.apache.fineract.infrastructure.core.api.JsonQuery; import java.time.temporal.ChronoUnit;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper; import java.util.HashMap;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper; import lombok.RequiredArgsConstructor;
import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator; import org.apache.fineract.infrastructure.core.api.JsonQuery;
import org.springframework.stereotype.Service; import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.organisation.monetary.domain.MoneyHelper;
@Service import org.apache.fineract.portfolio.savings.data.DepositAccountDataValidator;
@RequiredArgsConstructor import org.springframework.stereotype.Service;
public class FixedDepositAccountInterestCalculationServiceImpl implements FixedDepositAccountInterestCalculationService {
@Service
private final DepositAccountDataValidator depositAccountDataValidator; @RequiredArgsConstructor
private final FromJsonHelper fromApiJsonHelper; public class FixedDepositAccountInterestCalculationServiceImpl implements FixedDepositAccountInterestCalculationService {
@Override private final DepositAccountDataValidator depositAccountDataValidator;
public HashMap calculateInterest(JsonQuery query) { private final FromJsonHelper fromApiJsonHelper;
depositAccountDataValidator.validateFixedDepositForInterestCalculation(query.json());
JsonElement element = query.parsedJson(); @Override
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element); public HashMap calculateInterest(JsonQuery query) {
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element); depositAccountDataValidator.validateFixedDepositForInterestCalculation(query.json());
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element); JsonElement element = query.parsedJson();
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
element); BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
BigDecimal maturityAmount = this.calculateInterestInternal(principalAmount, annualInterestRate, tenureInMonths, Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
interestCompoundingPeriodInMonths); Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
String warning = "This is an approximate calculated amount - it may vary slightly when the account is created";
// Optional params for exact calendar-based calculation
HashMap result = new HashMap<>(); LocalDate submittedOnDate = null;
result.put("maturityAmount", maturityAmount); try {
result.put("warning", warning); final String dateStr = this.fromApiJsonHelper.extractStringNamed("submittedOnDate", element);
final String dateFormat = this.fromApiJsonHelper.extractStringNamed("dateFormat", element);
return result; if (dateStr != null && dateFormat != null) {
} submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
}
public BigDecimal calculateInterestInternal(BigDecimal principalAmount, BigDecimal annualInterestRate, Long tenureInMonths, } catch (Exception ignored) {}
Long interestCompoundingPeriodInMonths) { Integer daysInYear = null;
BigDecimal numberOfCompoundingsPerAnnum = BigDecimal.valueOf(12).divide(BigDecimal.valueOf(interestCompoundingPeriodInMonths)); final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
Long totalNumberOfCompoundings = tenureInMonths / interestCompoundingPeriodInMonths; if (daysInYearLong != null) daysInYear = daysInYearLong.intValue();
MathContext mc = MoneyHelper.getMathContext(); Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed("interestPostingPeriodInMonths", element);
BigDecimal exponentialTerm = annualInterestRate.divide(numberOfCompoundingsPerAnnum, mc).divide(BigDecimal.valueOf(100), mc)
.add(BigDecimal.valueOf(1)).pow(Math.toIntExact(totalNumberOfCompoundings)); BigDecimal maturityAmount;
return principalAmount.multiply(exponentialTerm); 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, final SavingsAccountData accountData = this.jdbcTemplate.queryForObject(sql, this.savingAccountMapper,
new Object[] { accountId }); // NOSONAR new Object[] { accountId }); // NOSONAR
// Load joint holders // Load joint holders with explicit camelCase keys (queryForList returns lowercase from MySQL)
if (accountData != null) { if (accountData != null) {
final String jointSql = "SELECT jh.id as id, jh.client_id as clientId, jh.cif_number as cifNumber, " final String jointSql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.display_name as displayName, jh.share_percentage as sharePercentage, " + "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
+ "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 = ? "
+ "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.queryForList(jointSql, accountId); 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()) { if (!jointHolders.isEmpty()) {
accountData.setJointHolders(jointHolders); accountData.setJointHolders(jointHolders);
} }