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:
co-authored by
Claude Sonnet 4.6
parent
7c4cf6260c
commit
a051faba5d
+47
-41
@@ -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);
|
||||
}
|
||||
|
||||
+16
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-6
@@ -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) {
|
||||
|
||||
+820
-818
File diff suppressed because it is too large
Load Diff
+42
@@ -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, "
|
||||
|
||||
+775
-702
File diff suppressed because it is too large
Load Diff
+48
-1
@@ -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;
|
||||
|
||||
|
||||
+832
-812
File diff suppressed because it is too large
Load Diff
+147
-74
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-6
@@ -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);
|
||||
}
|
||||
|
||||
+762
-752
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user