Merge branch 'dev-latest' into feature/bugfix
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s

This commit is contained in:
2026-09-24 18:09:48 +05:30
25 changed files with 889 additions and 37 deletions
@@ -123,6 +123,7 @@ public final class DepositsApiConstants {
public static final String isSeniorCitizenParamName = "isSeniorCitizen";
public static final String seniorCitizenInterestRateParamName = "seniorCitizenInterestRate";
public static final String tenureInMonthsParamName = "tenureInMonths";
public static final String tenureInDaysParamName = "tenureInDays";
public static final String interestCompoundingPeriodInMonthsParamName = "interestCompoundingPeriodInMonths";
// Preclosure parameters
@@ -142,6 +143,9 @@ public final class DepositsApiConstants {
public static final String inMultiplesOfDepositTermParamName = "inMultiplesOfDepositTerm";
public static final String inMultiplesOfDepositTermTypeIdParamName = "inMultiplesOfDepositTermTypeId";
public static final String inMultiplesOfDepositTermType = "inMultiplesOfDepositTermType";
// Days after maturity a depositor still has to walk in and renew at the original maturity date --
// null/0 means this product keeps the instant, same-night auto-renewal every FD product had before.
public static final String renewalGracePeriodDaysParamName = "renewalGracePeriodDays";
public static final String depositAmountParamName = "depositAmount";
public static final String depositMinAmountParamName = "minDepositAmount";
@@ -247,11 +251,13 @@ public final class DepositsApiConstants {
private static final Set<String> DEPOSIT_TERM_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermTypeIdParamName, maxDepositTermTypeIdParamName, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermTypeIdParamName, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> DEPOSIT_TERM_RESPONSE_DATA_PARAMETERS = new HashSet<>(Arrays.asList(minDepositTermParamName,
maxDepositTermParamName, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTermParamName,
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName));
inMultiplesOfDepositTermType, depositAmountParamName, depositMinAmountParamName, depositMaxAmountParamName,
renewalGracePeriodDaysParamName));
private static final Set<String> RECURRING_DETAILS_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(mandatoryRecommendedDepositAmountParamName, isMandatoryDepositParamName, allowWithdrawalParamName,
@@ -511,6 +517,7 @@ public final class DepositsApiConstants {
fixedDepositInterestCalculationData.add(principalAmountParamName);
fixedDepositInterestCalculationData.add(annualInterestRateParamName);
fixedDepositInterestCalculationData.add(tenureInMonthsParamName);
fixedDepositInterestCalculationData.add(tenureInDaysParamName);
fixedDepositInterestCalculationData.add(interestPostingPeriodInMonthsParamName);
fixedDepositInterestCalculationData.add(interestCompoundingPeriodInMonthsParamName);
// Exact calendar-based calculation params
@@ -418,13 +418,20 @@ public class CashierDepositRequestApiResource {
txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode,
amount, ptId, txnDateStr, description, true);
} else {
// Determine real account type by checking which table owns this ID.
// Determine real account type by checking which table/column owns this ID.
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS'
// even for current accounts (submitted before the frontend fix).
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
txnId = isCurrentAccount
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description)
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// even for current accounts (submitted before the frontend fix), and FD/RD accounts share
// the very same m_savings_account table as a plain savings account, distinguished only by
// deposit_type_enum -- so this is looked up fresh rather than trusted from the request.
final String depositCategory = resolveDepositAccountCategory(savingsAccountId);
final boolean isCurrentAccount = "CURRENT".equals(depositCategory);
txnId = switch (depositCategory) {
case "CURRENT" -> executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "FIXED_DEPOSIT" -> executeFixedDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
case "RECURRING_DEPOSIT" ->
executeRecurringDepositDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
default -> executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
};
if (!isCurrentAccount && txnId != null) {
// approve() is one @Transactional block, so an uncaught exception here would roll back
// the deposit that was just posted along with it -- every Cheque/DD approval that ever
@@ -718,6 +725,64 @@ public class CashierDepositRequestApiResource {
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeFixedDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.fixedDepositAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeRecurringDepositDeposit(
final Long accountId, final BigDecimal amount,
final Object paymentTypeId, final String txnDateStr, final String note) {
final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy"));
final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today;
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) {
payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue());
}
if (note != null && !note.isBlank()) {
payload.addProperty("note", note);
}
final CommandWrapper commandRequest = new CommandWrapperBuilder()
.recurringAccountDeposit(accountId)
.withJson(payload.toString())
.build();
final CommandProcessingResult result = voucherService
.withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest));
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
/**
* A Cheque/DD deposit is credited to the ledger immediately (same as cash) but is not really the
* branch's own money yet -- it still has to clear. Real-world CBS practice is to hold that amount
@@ -809,6 +874,32 @@ public class CashierDepositRequestApiResource {
}
}
/**
* "CURRENT", "FIXED_DEPOSIT", "RECURRING_DEPOSIT" or "SAVINGS" — checked against the database
* rather than the request's own account_type string. FD and RD accounts live in the same
* m_savings_account table as a plain savings account (deposit_type_enum 200/300 vs 100), so a
* plain "not current → savings" check would post an FD/RD deposit through the generic savings
* deposit command, which does not accept a term-deposit account id.
*/
private String resolveDepositAccountCategory(final Long accountId) {
if (accountId == null) {
return "SAVINGS";
}
if (isCurrentAccountById(accountId)) {
return "CURRENT";
}
final List<Integer> depositTypes = jdbcTemplate.queryForList(
"SELECT deposit_type_enum FROM m_savings_account WHERE id = ?", Integer.class, accountId);
final Integer depositType = depositTypes.isEmpty() ? null : depositTypes.get(0);
if (Integer.valueOf(200).equals(depositType)) {
return "FIXED_DEPOSIT";
}
if (Integer.valueOf(300).equals(depositType)) {
return "RECURRING_DEPOSIT";
}
return "SAVINGS";
}
// Parses a Fineract transaction id string to Long.
// Savings/current txn ids are decimal; GL journal entry ids are hex strings.
private Long parseTxnIdToLong(final String txnId) {
@@ -177,6 +177,15 @@ public class ClerkDisbursementRequestApiResource {
kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(),
"Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT);
}
// A Dr leg against a savings account is a withdrawal, and the clerk who raises it should
// not learn the account can't pay out until a manager tries to approve it and the whole
// request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would
// refuse this later -- inactive, closed, or still inside its lock-in period -- is checked
// here too, against the same columns, so the clerk sees the real reason at entry time.
if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType)
&& entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) {
validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt);
}
if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt);
} else {
@@ -1179,6 +1188,51 @@ public class ClerkDisbursementRequestApiResource {
.findFirst().orElse(null);
}
/** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */
private static final int SAVINGS_STATUS_ACTIVE = 300;
/**
* The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not
* active, or still inside its lock-in period -- checked here against the same columns, so the
* clerk sees the real reason when the request is raised rather than a generic failure once a
* manager tries to approve it. Balance is checked too, against the account's own minimum, since
* an entry that can never post is no more usable sitting in the approval queue than one refused
* outright.
*/
private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived,
min_required_balance, enforce_min_required_balance
FROM m_savings_account WHERE id = ?
""", savingsAccountId);
if (rows.isEmpty()) {
return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere
}
final Map<String, Object> row = rows.get(0);
final String accountNo = (String) row.get("account_no");
final Number statusEnum = (Number) row.get("status_enum");
if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active.");
}
final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived");
if (lockedUntil != null) {
final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
final LocalDate lockedUntilDate = lockedUntil.toLocalDate();
if (today.isBefore(lockedUntilDate)) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo
+ ": account is locked-in until " + lockedUntilDate + ".");
}
}
final BigDecimal balance = (BigDecimal) row.get("account_balance_derived");
final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance"));
final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null
? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO;
if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) {
throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance ("
+ balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ").");
}
}
private void addDisbursementVoucherLines(final Map<?, ?> record, final JsonArray creditEntries, final Long sourceGlId,
final String requestType) {
@@ -284,6 +284,7 @@ public class FixedDepositAccountsApiResource {
@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("tenureInDays") @Parameter(description = "Tenure in days (alternative to tenureInMonths, for sub-month FD Single tenures)") final Long tenureInDays,
@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,
@@ -291,6 +292,9 @@ public class FixedDepositAccountsApiResource {
HashMap request = new HashMap<>();
request.put("annualInterestRate", annualInterestRate);
request.put("tenureInMonths", tenureInMonths);
if (tenureInDays != null) {
request.put("tenureInDays", tenureInDays);
}
request.put("interestCompoundingPeriodInMonths", interestCompoundingPeriodInMonths);
request.put("interestPostingPeriodInMonths", interestPostingPeriodInMonths);
request.put("principalAmount", principalAmount);
@@ -0,0 +1,66 @@
/**
* 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.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's Renew action calls this as a pre-step, immediately before submitting the generic
* `payment` command (the same one the account's own Account Actions menu already uses) -- so the
* Dashboard offers every closure type that Payment does (Transfer to Savings, both Reinvest options,
* Partial Reinvest), not just the two {@code /renew} itself supports, while still getting the
* gap-period interest credited first when the product's renewal grace window has passed. A no-op
* (returns gapInterestAmount = 0) when still within grace, or when no grace is configured at all --
* see DepositAccountWritePlatformServiceJpaRepositoryImpl#creditGapInterestIfPastGrace.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/credit-gap-interest")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: credits gap-period interest before a late renewal.")
@RequiredArgsConstructor
public class FixedDepositGapInterestApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String creditGapInterest(@PathParam("accountId") final Long accountId) {
// Posts a real interest transaction when past grace, so it needs write/update, not just read.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final Map<String, Object> result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId);
return this.gson.toJson(result);
}
}
@@ -0,0 +1,96 @@
/**
* 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.api;
import com.google.gson.Gson;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
/**
* FD Dashboard's data source: every ACTIVE Fixed Deposit with just the fields the maturity ladder
* needs (account/client/product identity, maturity date, deposit and maturity amounts).
*
* Deliberately not routed through {@link FixedDepositAccountsApiResource#retrieveAll}: that
* endpoint's mapper joins m_savings_account to m_client, m_group, m_staff, m_currency,
* m_code_value and FIVE separate m_appuser joins (submitted/rejected/withdrawn/approved/activated/
* closed-by), plus a correlated subquery against m_savings_account_transaction per row -- all of it
* needed for the single-account and full-list screens it serves, none of it needed here. Neither
* m_savings_account.deposit_type_enum/status_enum nor
* m_deposit_account_term_and_preclosure.maturity_date had an index before this feature (confirmed
* directly against pg_indexes), so that query was a full table scan on top of being wide. This
* endpoint selects only what the dashboard renders, against the narrow, now-indexed WHERE clause
* this feature added (see migration 0571).
*/
@Path("/v1/fixeddepositaccounts/maturity-dashboard")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: active FDs positioned against their maturity date.")
@RequiredArgsConstructor
public class FixedDepositMaturityDashboardApiResource {
/** Fineract's DepositAccountType.FIXED_DEPOSIT. */
private static final int FIXED_DEPOSIT = 200;
/** SavingsAccountStatusType.ACTIVE. */
private static final int ACTIVE = 300;
private final PlatformSecurityContext context;
private final JdbcTemplate jdbcTemplate;
private final Gson gson = new Gson();
@GET
@Produces(MediaType.APPLICATION_JSON)
public String retrieve() {
// Same permission as the single-account/full-list FD endpoints -- this is a narrower read of
// the same resource, not a new one, so no new permission is introduced for it.
this.context.authenticatedUser().validateHasReadPermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final List<Map<String, Object>> rows = this.jdbcTemplate.queryForList("""
SELECT sa.id AS "id",
sa.account_no AS "accountNo",
sa.client_id AS "clientId",
c.display_name AS "clientName",
sp.name AS "productName",
sp.fd_type AS "fdType",
TO_CHAR(datp.maturity_date, 'YYYY-MM-DD') AS "maturityDate",
datp.deposit_amount AS "depositAmount",
datp.maturity_amount AS "maturityAmount",
datp.on_account_closure_enum AS "onClosureType",
sa.linked_account_id AS "linkedAccountId",
dptp.renewal_grace_period_days AS "renewalGracePeriodDays"
FROM m_savings_account sa
JOIN m_deposit_account_term_and_preclosure datp ON datp.savings_account_id = sa.id
JOIN m_savings_product sp ON sp.id = sa.product_id
LEFT JOIN m_deposit_product_term_and_preclosure dptp ON dptp.savings_product_id = sp.id
LEFT JOIN m_client c ON c.id = sa.client_id
WHERE sa.deposit_type_enum = ? AND sa.status_enum = ?
ORDER BY datp.maturity_date ASC
""", FIXED_DEPOSIT, ACTIVE);
return this.gson.toJson(rows);
}
}
@@ -0,0 +1,78 @@
/**
* 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.api;
import com.google.gson.Gson;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
/**
* FD Dashboard's "Renew" action: manually triggers the renewal of a matured FD -- backdated to the
* original maturity date within the product's renewal grace window (0 days if none is configured), or
* from today with a gap-period interest credit (at the linked Savings account's rate) once the grace
* window has passed. The staff's choice of Reinvest Principal + Interest vs Reinvest Principal Only is
* made here, at renewal time, via {@code reinvestType} in the request body -- the account's own stored
* maturity instruction (if it even has one; new SINGLE/DOUBLE FDs are opened without one) is not
* consulted. All the actual renewal and gap-interest logic lives in
* DepositAccountWritePlatformServiceJpaRepositoryImpl#renewMaturedFixedDeposit (it needs the same
* collaborators updateMaturityDetails() already has); this resource is just the HTTP entry point onto it.
*/
@Path("/v1/fixeddepositaccounts/{accountId}/renew")
@Component
@Tag(name = "Fixed Deposit Accounts", description = "FD Dashboard: manual renewal of a matured FD within/after its grace period.")
@RequiredArgsConstructor
public class FixedDepositRenewalApiResource {
private final PlatformSecurityContext context;
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
private final Gson gson = new Gson();
@POST
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String renew(@PathParam("accountId") final Long accountId,
@Parameter(hidden = true) final String apiRequestBodyAsJson) {
// A renewal moves money (or credits gap-period interest), so it needs the write/update
// permission on this resource, not just read -- unlike the dashboard's own read-only endpoint.
this.context.authenticatedUser().validateHasUpdatePermission(DepositsApiConstants.FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME);
final JsonObject body = (apiRequestBodyAsJson == null || apiRequestBodyAsJson.isBlank()) ? new JsonObject()
: JsonParser.parseString(apiRequestBodyAsJson).getAsJsonObject();
final Integer reinvestType = (body.has("reinvestType") && !body.get("reinvestType").isJsonNull())
? body.get("reinvestType").getAsInt()
: null;
final Map<String, Object> result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType);
return this.gson.toJson(result);
}
}
@@ -43,6 +43,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClos
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.principalAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.recurringFrequencyTypeParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferInterestToSavingsParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferToSavingsIdParamName;
@@ -681,8 +682,16 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(annualInterestRateParamName).value(annualInterestRate).notNull()
.notLessThanMin(BigDecimal.valueOf(0));
// FD Single accounts under 30 days are entered in whole days rather than months (a whole-month
// tenure would round down to 0 months); tenureInDays is an alternative to tenureInMonths, not
// an addition, so only one of the two needs to be a positive value.
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
if (tenureInDays != null && tenureInDays > 0) {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).ignoreIfNull().longGreaterThanZero();
} else {
baseDataValidator.reset().parameter(tenureInMonthsParamName).value(tenureInMonths).notNull().longGreaterThanZero();
}
Long interestPostingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestPostingPeriodInMonthsParamName, element);
baseDataValidator.reset().parameter(interestPostingPeriodInMonthsParamName).value(interestPostingPeriodInMonths).notNull()
@@ -355,6 +355,16 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
// an in-place renewal has nowhere to send. Left on the original flow rather than guessing.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
// Whatever is left after the principal moves into the new deposit is interest the customer
// earned and has to actually be paid out somewhere -- it must not just sit on this account,
// which is about to be closed. Validated before anything below runs, so a missing target
// fails cleanly with nothing done rather than leaving a half-completed reinvestment.
final BigDecimal leftoverInterest = maturedBalance.subtract(reInvestAmount);
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0 && toSavingsId == null) {
throw new GeneralPlatformDomainRuleException(
"error.msg.fixeddepositaccount.reinvest.principal.only.requires.savings.account",
"Reinvest Principal Only requires a Savings account to receive the leftover interest.");
}
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -384,6 +394,24 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
// Pay out whatever didn't move into the new deposit, then actually close this account --
// previously this branch left the leftover interest stranded on an account that was never
// closed, silently, because nothing past this point ever referenced toSavingsId at all.
if (leftoverInterest.compareTo(BigDecimal.ZERO) > 0) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO leftoverTransferDTO = new AccountTransferDTO(closedDate, leftoverInterest,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null,
"Reinvest Principal Only -- interest paid out", null, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(leftoverTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -414,20 +414,33 @@ public class SavingsAccountAssembler {
// dependency on the shareaccounts domain class) rather than trusted blindly, the same way an
// account number typed for a linked savings/loan account elsewhere in this form is verified
// before being accepted.
//
// For a term deposit (FD/RD), the product screen labels this same flag "Deposit Account Link" --
// it links an existing Savings/Current account (linkedAccountId, set below), not a co-op share
// account, so it's validated against that field instead of shareAccountId.
if (Boolean.TRUE.equals(account.getShareLinkingEnabled())) {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
final boolean isTermDeposit = account.depositAccountType().isFixedDeposit() || account.depositAccountType().isRecurringDeposit();
if (isTermDeposit) {
final Long linkedAccountIdForCheck = this.fromApiJsonHelper.extractLongNamed(linkedAccountIdParamName, element);
if (linkedAccountIdForCheck == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.linked.account.required",
"A Savings/Current account must be linked when Deposit Account Link is enabled.");
}
} else {
final Long shareAccountId = this.fromApiJsonHelper.extractLongNamed(shareAccountIdParamName, element);
if (shareAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.required",
"A share account must be chosen when Share Linking is enabled.");
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
final Integer matches = this.jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM m_share_account WHERE id = ? AND client_id = ? AND status_enum = 300", Integer.class,
shareAccountId, account.clientId());
if (matches == null || matches == 0) {
throw new GeneralPlatformDomainRuleException("error.msg.savingsaccount.share.account.not.active.for.client",
"Share account " + shareAccountId + " is not an active share account for this client.");
}
account.setShareAccountId(shareAccountId);
}
// Senior citizen rate — runs after validateDomainRules so it is the final persisted value
@@ -28,6 +28,7 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
@@ -86,6 +87,7 @@ import org.apache.fineract.portfolio.note.domain.Note;
import org.apache.fineract.portfolio.note.domain.NoteRepository;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.service.PaymentDetailWritePlatformService;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
import org.apache.fineract.portfolio.savings.DepositsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
@@ -99,6 +101,7 @@ import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransact
import org.apache.fineract.portfolio.savings.domain.DepositAccountOnHoldTransactionRepository;
import org.apache.fineract.portfolio.savings.domain.DepositAccountRecurringDetail;
import org.apache.fineract.portfolio.savings.domain.FixedDepositAccount;
import org.apache.fineract.portfolio.savings.domain.FixedDepositProduct;
import org.apache.fineract.portfolio.savings.domain.RecurringDepositAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountCharge;
@@ -112,6 +115,7 @@ import org.apache.fineract.portfolio.savings.exception.DepositAccountTransaction
import org.apache.fineract.portfolio.savings.exception.SavingsAccountTransactionNotFoundException;
import org.apache.fineract.portfolio.savings.exception.TransactionUpdateNotAllowedException;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
@Slf4j
@@ -142,6 +146,7 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
private final WorkingDaysRepositoryWrapper workingDaysRepository;
private final DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository;
private final DenominationBreakdownWriter denominationBreakdownWriter;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -1512,23 +1517,53 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (depositAccountType.isFixedDeposit()) {
((FixedDepositAccount) account).updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
FixedDepositAccount fdAccount = ((FixedDepositAccount) account);
// Post the interest transaction(s) updateMaturityStatus() just created BEFORE any maturity
// closure below runs. The closure's own withdrawal (handleWithdrawal -> postJournalEntries)
// snapshots "existing" transactions the moment IT starts, which by then already includes
// this interest posting -- so its own posting treats the interest as already accounted for
// and skips it, while this method's own final postJournalEntries() call (previously relying
// on the interest staying out of existingTransactionIds so it would post there instead)
// never actually saw it get posted either: for an FD whose entire term is a single posting
// period -- interest posts and the account matures in this same run -- that left the
// interest transaction with a real ledger balance but no matching GL entry at all, forever.
// Posting it now, while it is still the only new thing on the account, gives it exactly one
// correct journal entry, and folding it into existingTransactionIds below (the same way
// markWithdrawalsAsAlreadyPosted() already does for the withdrawal) keeps the closure branch
// and the final call from ever trying to post it again.
this.savingAccountRepositoryWrapper.saveAndFlush(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
// handle maturity instructions
// An account opened WITH an explicit maturity instruction (Transfer to Savings or either
// Reinvest option) auto-processes here, tonight, exactly as every FD always has -- the
// renewal grace period never delays this, no matter what the product has configured. Grace
// only matters for an account that was opened with NO instruction at all: that one is never
// touched here (isReinvestOnClosure()/isTransferToSavingsOnClosure() are both false for a
// null closure type), so it just sits matured until a human renews it from the FD Dashboard
// -- see renewMaturedFixedDeposit(), which is where the grace window is actually read.
if (fdAccount.isMatured() && (fdAccount.isReinvestOnClosure() || fdAccount.isTransferToSavingsOnClosure())) {
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
Map<String, Object> changes = new HashMap<>();
final AppUser user = context.authenticatedUser();
Long toSavingsId = fdAccount.getTransferToSavingsAccountId();
// Reinvest Principal Only needs a target for the leftover interest (see
// DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure) -- the account-opening
// form only collects transferToSavingsId when Transfer to Savings itself was chosen, so
// an account opened straight into Reinvest Principal Only can reach here with none set.
// Falls back to the FD's own Deposit Account Link rather than failing the whole job run.
if (toSavingsId == null && fdAccount.isReinvestOnClosure()) {
toSavingsId = fdAccount.getLinkedAccountId();
}
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, fdAccount.maturityDate(),
fdAccount.getOnAccountClosureId(), toSavingsId, "Apply maturity instructions", changes);
// Every maturity-instruction branch takes the balance out of this account through
// SavingsAccountDomainServiceJpa#handleWithdrawal, which posts that withdrawal's journal
// entries itself. existingTransactionIds was snapshotted at the top of this method --
// before that withdrawal existed -- so the postJournalEntries() call at the end would
// treat it as new and post it a second time. Interest postings created by
// updateMaturityStatus() above are still genuinely unposted at this point and must stay
// out of the snapshot so that final call can post them exactly once.
// entries itself. existingTransactionIds was refreshed just above (after the interest was
// posted) -- before that withdrawal existed -- so the postJournalEntries() call at the end
// would treat it as new and post it a second time without this.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
@@ -1563,6 +1598,171 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds);
}
@Transactional
@Override
public Map<String, Object> renewMaturedFixedDeposit(final Long fdAccountId, final Integer reinvestType) {
final AppUser user = context.authenticatedUser();
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet -- nothing to renew.");
}
// The account's own stored maturity instruction is no longer authoritative -- new SINGLE/DOUBLE
// FDs are opened without one at all (see create-fixed-deposit-account.component.ts), because
// what happens at maturity is now decided here, by staff, at the moment of renewal.
final DepositAccountOnClosureType chosenClosureType = DepositAccountOnClosureType.fromInt(reinvestType);
if (!chosenClosureType.isReinvestPrincipalAndInterest() && !chosenClosureType.isReinvestPrincipal()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.reinvest.type.required",
"Choose Reinvest Principal + Interest or Reinvest Principal Only.");
}
// Only Principal pays the leftover interest out to the FD's own linked Savings account -- the
// account-opening wizard makes a Deposit Account Link mandatory for SINGLE/DOUBLE FDs, so this
// should always be present, but fail clearly rather than silently stranding the interest if not.
final Long linkedSavingsAccountId = fdAccount.getLinkedAccountId();
if (chosenClosureType.isReinvestPrincipal() && linkedSavingsAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD has no Deposit Account Link -- link a Savings account to it before renewing with Principal Only, "
+ "so the leftover interest has somewhere to go.");
}
// Renewal grace period only shrinks the backdate window below -- it is no longer a precondition
// for whether Renew is allowed at all. A product that never configured one behaves as a 0-day
// window: renewing on the maturity date itself backdates, any later renewal pays gap interest.
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final LocalDate graceDeadline = maturityDate.plusDays(renewalGracePeriodDays);
final boolean withinGrace = !today.isAfter(graceDeadline);
final Map<String, Object> result = new HashMap<>();
BigDecimal gapInterestAmount = null;
if (!withinGrace) {
// Late: the backdated renewal is no longer offered. The gap between the original maturity
// date and today is compensated at the linked Savings account's own product rate (simple
// interest for that stretch) before the FD renews from today, so the new term's principal
// already includes it.
gapInterestAmount = creditGapInterest(fdAccount, maturityDate, today);
}
final LocalDate renewalDate = withinGrace ? maturityDate : today;
final Map<String, Object> changes = new HashMap<>();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
this.depositAccountDomainService.handleFDAccountMaturityClosure(fdAccount, null, user, fmt, renewalDate, reinvestType,
chosenClosureType.isReinvestPrincipal() ? linkedSavingsAccountId : null,
withinGrace ? "Manual renewal within grace period" : "Manual renewal after grace period, gap interest credited at linked savings rate",
changes);
markWithdrawalsAsAlreadyPosted(fdAccount, existingTransactionIds);
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
// REINVEST_PRINCIPAL_ONLY closes the old account and opens a new one (reinvestedDepositId);
// REINVEST_PRINCIPAL_AND_INTEREST renews the same account in place -- report the account that
// actually carries the new term's maturity date/amount either way.
FixedDepositAccount renewedAccount = fdAccount;
if (changes.get("reinvestedDepositId") != null) {
renewedAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom((Long) changes.get("reinvestedDepositId"),
DepositAccountType.FIXED_DEPOSIT);
}
result.put("renewedAccountId", renewedAccount.getId());
result.put("maturityDate", renewedAccount.maturityDate() != null ? renewedAccount.maturityDate().toString() : null);
result.put("maturityAmount", renewedAccount.maturityAmount());
result.put("backdated", withinGrace);
if (gapInterestAmount != null) {
result.put("gapInterestAmount", gapInterestAmount);
}
return result;
}
@Transactional
@Override
public Map<String, Object> creditGapInterestIfPastGrace(final Long fdAccountId) {
final FixedDepositAccount fdAccount = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(fdAccountId,
DepositAccountType.FIXED_DEPOSIT);
if (!fdAccount.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.not.matured",
"This FD has not matured yet.");
}
Integer renewalGracePeriodDays = null;
if (fdAccount.savingsProduct() instanceof FixedDepositProduct fdProduct && fdProduct.depositProductTermAndPreClosure() != null) {
renewalGracePeriodDays = fdProduct.depositProductTermAndPreClosure().depositTermDetail().renewalGracePeriodDays();
}
if (renewalGracePeriodDays == null || renewalGracePeriodDays < 0) {
renewalGracePeriodDays = 0;
}
final LocalDate maturityDate = fdAccount.maturityDate();
final LocalDate today = DateUtils.getBusinessLocalDate();
final boolean withinGrace = !today.isAfter(maturityDate.plusDays(renewalGracePeriodDays));
final Map<String, Object> result = new HashMap<>();
result.put("withinGrace", withinGrace);
result.put("gapInterestAmount", withinGrace ? BigDecimal.ZERO : creditGapInterest(fdAccount, maturityDate, today));
return result;
}
/**
* Simple interest for the stretch between the FD's own maturity date and today, at the rate of its
* linked Savings/Current account's product -- credited onto the FD itself as a genuine
* INTEREST_POSTING transaction (not a DEPOSIT, which would wrongly count as cash received in the
* cashier/vault reconciliation) before whatever the caller does next (a manual renewal, or the FD
* Dashboard's generic Payment command) reads the account's balance. Shared by
* {@link #renewMaturedFixedDeposit} and {@link #creditGapInterestIfPastGrace} -- the FD Dashboard's
* Renew action now calls the latter as a pre-step before submitting the same generic `payment`
* command the account's own Account Actions menu already uses, so every closure type (Transfer to
* Savings, both Reinvest options, Partial Reinvest) is available from the Dashboard too, not just the
* two this method's sibling endpoint originally supported.
*/
private BigDecimal creditGapInterest(final FixedDepositAccount fdAccount, final LocalDate maturityDate, final LocalDate today) {
final Long linkedAccountId = fdAccount.getLinkedAccountId();
if (linkedAccountId == null) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddepositaccount.renewal.linked.account.required",
"This FD's grace period has passed and it has no Deposit Account Link -- link a Savings/Current account to it "
+ "before renewing late, so the gap-period interest has a rate to use.");
}
final BigDecimal linkedAccountRate = this.jdbcTemplate.queryForObject("""
SELECT sp.nominal_annual_interest_rate FROM m_savings_account sa
JOIN m_savings_product sp ON sp.id = sa.product_id
WHERE sa.id = ?
""", BigDecimal.class, linkedAccountId);
final long gapDays = ChronoUnit.DAYS.between(maturityDate, today);
final int daysInYear = fdAccount.getInterestCalculationDaysInYearType() == null ? 365
: fdAccount.getInterestCalculationDaysInYearType();
final MathContext mc = MoneyHelper.getMathContext();
final BigDecimal gapInterestAmount = fdAccount.getAccountBalance().multiply(linkedAccountRate, mc)
.multiply(BigDecimal.valueOf(gapDays), mc).divide(BigDecimal.valueOf((long) daysInYear * 100), mc)
.setScale(2, java.math.RoundingMode.HALF_UP);
if (gapInterestAmount.compareTo(BigDecimal.ZERO) > 0) {
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(fdAccount, existingTransactionIds, existingReversedTransactionIds);
final SavingsAccountTransaction gapInterestTxn = SavingsAccountTransaction.interestPosting(fdAccount, fdAccount.office(),
today, Money.of(fdAccount.getCurrency(), gapInterestAmount), true);
fdAccount.addTransaction(gapInterestTxn);
fdAccount.updateSavingsAccountSummary(fdAccount.getTransactions());
this.savingAccountRepositoryWrapper.saveAndFlush(fdAccount);
postJournalEntries(fdAccount, existingTransactionIds, existingReversedTransactionIds);
}
return gapInterestAmount;
}
private void updateExistingTransactionsDetails(SavingsAccount account, Set<Long> existingTransactionIds,
Set<Long> existingReversedTransactionIds) {
existingTransactionIds.addAll(account.findExistingTransactionIds());
@@ -309,6 +309,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("dptp.in_multiples_of_deposit_term_type_enum as inMultiplesOfDepositTermTypeId, ");
sqlBuilder.append("dptp.min_deposit_amount as minDepositAmount, dptp.deposit_amount as depositAmount, ");
sqlBuilder.append("dptp.max_deposit_amount as maxDepositAmount, ");
sqlBuilder.append("dptp.renewal_grace_period_days as renewalGracePeriodDays, ");
sqlBuilder.append("sp.account_type as accountType, sp.fd_type as fdType, sp.product_code as productCode, ");
sqlBuilder.append("sp.customer_segment as customerSegment, ");
sqlBuilder.append("sp.gl_account_id as glAccountId, gla.name as glAccountName, ");
@@ -385,6 +386,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
// CBS Custom Fields
result.setAccountType(rs.getString("accountType"));
result.setFdType(rs.getString("fdType"));
result.setRenewalGracePeriodDays(JdbcSupport.getInteger(rs, "renewalGracePeriodDays"));
result.setProductCode(rs.getString("productCode"));
result.setCustomerSegment(rs.getString("customerSegment"));
result.setGlAccountId(JdbcSupport.getLong(rs, "glAccountId"));
@@ -22,6 +22,7 @@ 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.tenureInDaysParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.tenureInMonthsParamName;
import com.google.gson.JsonElement;
@@ -52,6 +53,7 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal principalAmount = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(principalAmountParamName, element);
BigDecimal annualInterestRate = this.fromApiJsonHelper.extractBigDecimalWithLocaleNamed(annualInterestRateParamName, element);
Long tenureInMonths = this.fromApiJsonHelper.extractLongNamed(tenureInMonthsParamName, element);
Long tenureInDays = this.fromApiJsonHelper.extractLongNamed(tenureInDaysParamName, element);
Long interestCompoundingPeriodInMonths = this.fromApiJsonHelper.extractLongNamed(interestCompoundingPeriodInMonthsParamName, element);
// Optional params for exact calendar-based calculation
@@ -62,8 +64,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
if (dateStr != null && dateFormat != null) {
submittedOnDate = LocalDate.parse(dateStr, java.time.format.DateTimeFormatter.ofPattern(dateFormat));
}
} catch (Exception ignored) {
// intentionally ignored
} catch (Exception ignored) {
// intentionally ignored
}
Integer daysInYear = null;
final Long daysInYearLong = this.fromApiJsonHelper.extractLongNamed("daysInYear", element);
@@ -73,7 +75,8 @@ public class FixedDepositAccountInterestCalculationServiceImpl implements FixedD
BigDecimal maturityAmount;
if (submittedOnDate != null && daysInYear != null && interestPostingPeriodInMonths != null) {
// Exact calendar-based calculation — same algorithm as account creation
final LocalDate maturityDate = submittedOnDate.plusMonths(tenureInMonths);
final LocalDate maturityDate = (tenureInDays != null && tenureInDays > 0) ? submittedOnDate.plusDays(tenureInDays)
: submittedOnDate.plusMonths(tenureInMonths);
maturityAmount = this.calculateExact(principalAmount, annualInterestRate, submittedOnDate, maturityDate,
interestCompoundingPeriodInMonths, interestPostingPeriodInMonths, daysInYear);
} else {
@@ -297,7 +297,7 @@ public class SavingsConfiguration {
ConfigurationDomainService configurationDomainService, HolidayRepositoryWrapper holidayRepository,
WorkingDaysRepositoryWrapper workingDaysRepository,
DepositAccountOnHoldTransactionRepository depositAccountOnHoldTransactionRepository,
DenominationBreakdownWriter denominationBreakdownWriter
DenominationBreakdownWriter denominationBreakdownWriter, JdbcTemplate jdbcTemplate
) {
return new DepositAccountWritePlatformServiceJpaRepositoryImpl(context, savingAccountRepositoryWrapper,
@@ -307,7 +307,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, accountAssociationsReadPlatformService,
accountTransfersWritePlatformService, depositAccountReadPlatformService, calendarInstanceRepository,
configurationDomainService, holidayRepository, workingDaysRepository, depositAccountOnHoldTransactionRepository,
denominationBreakdownWriter);
denominationBreakdownWriter, jdbcTemplate);
}
@Bean
@@ -116,6 +116,17 @@
<include file="parts/0568_add_settlement_mode_to_clerk_share_withdrawal_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0569_aml_alert_release_stale_under_review.xml" relativeToChangelogFile="true"/>
<include file="parts/0570_add_dd_payable_gl_to_sponsor_bank.xml" relativeToChangelogFile="true"/>
<include file="parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_add_renewal_grace_period_to_deposit_product_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0573_add_renewal_grace_period_to_saving_product_temp.xml" relativeToChangelogFile="true"/>
<include file="parts/0574_add_renewal_grace_period_to_deposit_account_term.xml" relativeToChangelogFile="true"/>
<!-- NOTE: numbered 0571/0572 like the dev-latest FD migrations directly above - both were added
independently (this Maker-Checker work on feature/bugfix, the FD work on dev-latest) before
either branch saw the other's changes, so the numeric prefixes collide. Left as originally
named (not renumbered) since only the changelog include list, not the migration files
themselves, is in scope for this merge; Liquibase identifies changesets by id+author+file
path, not by filename number, so the collision is cosmetic only and does not affect
correctness. -->
<include file="parts/0571_add_sponsor_dd_gl_mapping_permission.xml" relativeToChangelogFile="true"/>
<include file="parts/0572_add_sponsor_bank_update_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
The FD Dashboard's maturity-dashboard endpoint filters m_savings_account by
(deposit_type_enum = 200, status_enum = 300) and orders by
m_deposit_account_term_and_preclosure.maturity_date. Neither column was indexed, confirmed
by querying pg_indexes directly before adding this, so both were full table scans, which
only gets slower as the FD book grows. These two indexes are what actually makes that
endpoint fast; the endpoint's own SQL was already written lean, but a lean query still scans
the whole table without something to seek on.
-->
<changeSet id="0571_index_savings_account_deposit_type_status" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_msa_deposit_type_status"/></not>
</preConditions>
<createIndex tableName="m_savings_account" indexName="idx_msa_deposit_type_status">
<column name="deposit_type_enum"/>
<column name="status_enum"/>
</createIndex>
</changeSet>
<changeSet id="0571_index_deposit_account_term_preclosure_maturity_date" author="CBS">
<preConditions onFail="MARK_RAN">
<not><indexExists indexName="idx_datp_maturity_date"/></not>
</preConditions>
<createIndex tableName="m_deposit_account_term_and_preclosure" indexName="idx_datp_maturity_date">
<column name="maturity_date"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
Days after maturity a depositor can still walk in and have their FD renewed backdated to the
original maturity date, at the currently active rate. Null/0 (every existing product, since
this column is brand new) means "no grace period configured", so that product keeps the
instant, same-night auto-renewal it always had; see
DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips
the automatic renewal when this is a positive value. So adding this column changes nothing
for any product until an admin explicitly sets a grace period on it.
-->
<changeSet id="0572_add_renewal_grace_period_to_deposit_product_term" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_deposit_product_term_and_preclosure" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_deposit_product_term_and_preclosure">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
m_saving_product_temp is a full field-by-field mirror of the product tables, used by the
maker-checker "pending product edit" flow (ProductPendingEditFieldMapper /
FixedDepositEditFields, an @Embeddable mapped directly onto this table). It is a separate
storage path from the live product tables migration 0572 added a column to, so
renewalGracePeriodDays needs its own column here too, or a governed FD/RD product edit that
sets it silently drops the value: the checker's "Proposed changes" screen never shows it,
and approving the edit never carries it forward, even though the value was actually typed in
and submitted.
-->
<changeSet id="0573_add_renewal_grace_period_to_saving_product_temp" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_saving_product_temp" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_saving_product_temp">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!--
DepositTermDetail (the @Embeddable that 0572 added renewal_grace_period_days to) is embedded
by BOTH DepositProductTermAndPreClosure (m_deposit_product_term_and_preclosure, the product's
own term settings, covered by 0572) AND DepositAccountTermAndPreClosure
(m_deposit_account_term_and_preclosure, one row per FD/RD account, created fresh on every
account submission). An @Embeddable's columns are expected on every table it's embedded into,
so without this column here too, FD account creation fails outright with a Postgres "column
renewal_grace_period_days does not exist" error on the INSERT into this table, confirmed by a
live devRun stack trace, not a hypothetical. Nullable, no default: existing/new account rows
simply don't carry a grace period of their own (the product's value is what actually gates the
nightly job and the Renew action), so this column is unused today but has to exist for
EclipseLink's generated INSERT/UPDATE statements to succeed at all.
-->
<changeSet id="0574_add_renewal_grace_period_to_deposit_account_term" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_deposit_account_term_and_preclosure" columnName="renewal_grace_period_days"/></not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="renewal_grace_period_days" type="INTEGER"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -496,6 +496,7 @@ public final class FixedDepositProductData extends DepositProductData {
private Boolean accountClosedZeroBalanceInDayClosing;
private String balanceFrequency;
private Boolean isPendingApproval;
private Integer renewalGracePeriodDays;
// Template option setters
public void setInterestCalculationTypeOption(Collection<org.apache.fineract.infrastructure.core.data.EnumOptionData> v) { this.interestCalculationTypeOption = v; }
@@ -543,6 +544,7 @@ public final class FixedDepositProductData extends DepositProductData {
public void setAccountClosedZeroBalanceInDayClosing(Boolean v) { this.accountClosedZeroBalanceInDayClosing = v; }
public void setBalanceFrequency(String v) { this.balanceFrequency = v; }
public void setIsPendingApproval(Boolean v) { this.isPendingApproval = v; }
public void setRenewalGracePeriodDays(Integer v) { this.renewalGracePeriodDays = v; }
// CBS field getters
public String getProductCode() { return productCode; }
@@ -559,6 +561,7 @@ public final class FixedDepositProductData extends DepositProductData {
public Long getDaysToInactive() { return daysToInactive; }
public Long getDaysToDormancy() { return daysToDormancy; }
public Long getDaysToEscheat() { return daysToEscheat; }
public Integer getRenewalGracePeriodDays() { return renewalGracePeriodDays; }
public void copyCbsFieldsFrom(FixedDepositProductData src) {
if (src == null) return;
@@ -605,6 +608,7 @@ public final class FixedDepositProductData extends DepositProductData {
this.accountClosedZeroBalanceInDayClosing = src.accountClosedZeroBalanceInDayClosing;
this.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays;
}
private Boolean withdrawalFeeForTransfers;
@@ -36,6 +36,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalApplicableParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestOnTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.preClosurePenalInterestParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.chargesParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.currencyCodeParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.descriptionParamName;
@@ -473,8 +474,10 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName));
return depositTermDetail;
}
public DepositTermDetail assembleDepositTermDetail(final JsonCommand command, final DepositTermDetail prodDepositTermDetail) {
@@ -531,8 +534,17 @@ public class DepositProductAssembler {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = (inMultiplesOfDepositTermTypeId == null) ? null
: SavingsPeriodFrequencyType.fromInt(inMultiplesOfDepositTermTypeId);
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
Integer renewalGracePeriodDays = null;
if (command.parameterExists(renewalGracePeriodDaysParamName)) {
renewalGracePeriodDays = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
} else if (prodDepositTermDetail != null) {
renewalGracePeriodDays = prodDepositTermDetail.renewalGracePeriodDays();
}
final DepositTermDetail depositTermDetail = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
depositTermDetail.setRenewalGracePeriodDays(renewalGracePeriodDays);
return depositTermDetail;
}
public DepositRecurringDetail assembleRecurringDetail(final JsonCommand command) {
@@ -24,6 +24,7 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepo
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.maxDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.minDepositTermTypeIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.renewalGracePeriodDaysParamName;
import jakarta.persistence.Column;
import jakarta.persistence.Embeddable;
@@ -61,6 +62,14 @@ public class DepositTermDetail {
@Column(name = "in_multiples_of_deposit_term_type_enum", nullable = true)
private Integer inMultiplesOfDepositTermType;
// Days after maturity a depositor can still walk in and renew backdated to the original maturity
// date, at the currently active rate. Null/0 means this product keeps the instant, same-night
// auto-renewal every FD product had before this field existed -- see
// DepositAccountWritePlatformServiceJpaRepositoryImpl#updateMaturityDetails, which only skips the
// automatic renewal when this is a positive value.
@Column(name = "renewal_grace_period_days", nullable = true)
private Integer renewalGracePeriodDays;
public static DepositTermDetail createFrom(final Integer minDepositTerm, final Integer maxDepositTerm,
final SavingsPeriodFrequencyType minDepositTermType, final SavingsPeriodFrequencyType maxDepositTermType,
final Integer inMultiplesOfDepositTerm, final SavingsPeriodFrequencyType inMultiplesOfDepositTermType) {
@@ -123,6 +132,12 @@ public class DepositTermDetail {
this.inMultiplesOfDepositTermType = newValue;
}
if (command.isChangeInIntegerParameterNamed(renewalGracePeriodDaysParamName, this.renewalGracePeriodDays)) {
final Integer newValue = command.integerValueOfParameterNamed(renewalGracePeriodDaysParamName);
actualChanges.put(renewalGracePeriodDaysParamName, newValue);
this.renewalGracePeriodDays = newValue;
}
if (isMinDepositTermGreaterThanMaxDepositTerm()) {
baseDataValidator.parameter(minDepositTermParamName).value(this.minDepositTerm).failWithCode(".greater.than.maxDepositTerm",
this.minDepositTerm, this.maxDepositTerm);
@@ -166,6 +181,14 @@ public class DepositTermDetail {
return this.inMultiplesOfDepositTermType;
}
public Integer renewalGracePeriodDays() {
return this.renewalGracePeriodDays;
}
public void setRenewalGracePeriodDays(final Integer renewalGracePeriodDays) {
this.renewalGracePeriodDays = renewalGracePeriodDays;
}
public boolean isDepositBetweenMinAndMax(LocalDate depositStartDate, LocalDate depositEndDate) {
return isEqualOrGreaterThanMin(depositStartDate, depositEndDate) && isEqualOrLessThanMax(depositStartDate, depositEndDate);
}
@@ -260,7 +283,9 @@ public class DepositTermDetail {
final SavingsPeriodFrequencyType inMultiplesOfDepositTermType = SavingsPeriodFrequencyType
.fromInt(this.inMultiplesOfDepositTermType());
return DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType,
inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
final DepositTermDetail copy = DepositTermDetail.createFrom(minDepositTerm, maxDepositTerm, minDepositTermType,
maxDepositTermType, inMultiplesOfDepositTerm, inMultiplesOfDepositTermType);
copy.renewalGracePeriodDays = this.renewalGracePeriodDays;
return copy;
}
}
@@ -68,4 +68,7 @@ public class FixedDepositEditFields {
@Column(name = "deposit_amount")
private BigDecimal depositAmount;
@Column(name = "renewal_grace_period_days")
private Integer renewalGracePeriodDays;
}
@@ -942,6 +942,10 @@ public final class ProductPendingEditFieldMapper {
f.setDepositAmount(command.bigDecimalValueOfParameterNamed("depositAmount"));
present.add("depositAmount");
}
if (command.hasParameter("renewalGracePeriodDays")) {
f.setRenewalGracePeriodDays(command.integerValueOfParameterNamed("renewalGracePeriodDays"));
present.add("renewalGracePeriodDays");
}
return present;
}
@@ -962,6 +966,7 @@ public final class ProductPendingEditFieldMapper {
if (f.getMinDepositAmount() != null) putNumber(root, submitted, "minDepositAmount", f.getMinDepositAmount());
if (f.getMaxDepositAmount() != null) putNumber(root, submitted, "maxDepositAmount", f.getMaxDepositAmount());
if (f.getDepositAmount() != null) putNumber(root, submitted, "depositAmount", f.getDepositAmount());
if (f.getRenewalGracePeriodDays() != null) putNumber(root, submitted, "renewalGracePeriodDays", f.getRenewalGracePeriodDays());
}
public static Set<String> populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) {
@@ -92,5 +92,27 @@ public interface DepositAccountWritePlatformService {
void updateMaturityDetails(Long depositAccountId, DepositAccountType depositAccountType);
/**
* Manually renews a matured FD from the FD Dashboard -- backdated to the original maturity date if
* still within the product's renewal grace window (0 days if none is configured), or from today
* with a gap-period interest credit (at the linked Savings account's product rate) if not.
* {@code reinvestType} is the staff's choice made at renewal time (300 = Reinvest Principal and
* Interest, 400 = Reinvest Principal Only -- the rest of the balance is paid to the FD's linked
* Savings account); the account's own stored maturity instruction, if any, is not consulted.
* Returns a small result map: renewedAccountId, maturityDate, maturityAmount, backdated (boolean),
* gapInterestAmount (BigDecimal, present only when backdated is false).
*/
Map<String, Object> renewMaturedFixedDeposit(Long fdAccountId, Integer reinvestType);
/**
* Credits gap-period interest (simple interest, at the linked Savings/Current account's product
* rate) onto a matured FD if its product's renewal grace window has passed -- a no-op, returning
* zero, if still within it. Called as a pre-step before the FD Dashboard's Renew action submits the
* generic `payment` command (the same one the account's own Account Actions menu uses), so every
* closure type is available from the Dashboard, not just the two {@link #renewMaturedFixedDeposit}
* itself supports. Returns {@code withinGrace} (boolean) and {@code gapInterestAmount} (BigDecimal).
*/
Map<String, Object> creditGapInterestIfPastGrace(Long fdAccountId);
SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO);
}