diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index 240914b13..03c685961 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -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 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 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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 7378801ec..25b268f90 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 08d596d26..e75955127 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -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> 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 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) { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java index d0b4aa3f5..830f6f5a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositAccountsApiResource.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java new file mode 100644 index 000000000..dd5191542 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositGapInterestApiResource.java @@ -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 result = this.depositAccountWritePlatformService.creditGapInterestIfPastGrace(accountId); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java new file mode 100644 index 000000000..c1a1c8b51 --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositMaturityDashboardApiResource.java @@ -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> 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); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java new file mode 100644 index 000000000..419e6247f --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/api/FixedDepositRenewalApiResource.java @@ -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 result = this.depositAccountWritePlatformService.renewMaturedFixedDeposit(accountId, reinvestType); + return this.gson.toJson(result); + } +} diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java index 3aa401060..34d15fdab 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/data/DepositAccountDataValidator.java @@ -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() diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java index 62d049e95..a884d9f19 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositAccountDomainServiceJpa.java @@ -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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java index 8453b6e92..22cef0438 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsAccountAssembler.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java index 2ce19027e..4484134c7 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformServiceJpaRepositoryImpl.java @@ -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 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 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 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 changes = new HashMap<>(); + final DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + final Set existingTransactionIds = new HashSet<>(); + final Set 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 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 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 existingTransactionIds = new HashSet<>(); + final Set 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 existingTransactionIds, Set existingReversedTransactionIds) { existingTransactionIds.addAll(account.findExistingTransactionIds()); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index d431acb5b..24f1b6aaf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -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")); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java index 061e1ce94..0aed9ad73 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/FixedDepositAccountInterestCalculationServiceImpl.java @@ -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 { diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java index 1eef688a9..976d2e6ce 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/starter/SavingsConfiguration.java @@ -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 diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 361f2e488..461368aee 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -116,6 +116,17 @@ + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml new file mode 100644 index 000000000..4bf61c077 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0571_index_savings_account_deposit_type_status_and_maturity_date.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml new file mode 100644 index 000000000..cce65c650 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0572_add_renewal_grace_period_to_deposit_product_term.xml @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml new file mode 100644 index 000000000..119cefcc6 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0573_add_renewal_grace_period_to_saving_product_temp.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml new file mode 100644 index 000000000..ac4917332 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0574_add_renewal_grace_period_to_deposit_account_term.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 53c2e9199..88004012f 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -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 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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 96d4996d1..64402d2e7 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -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) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java index 360e64ada..e892c733d 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositTermDetail.java @@ -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; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java index 8699d91cb..360a9d6dd 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/FixedDepositEditFields.java @@ -68,4 +68,7 @@ public class FixedDepositEditFields { @Column(name = "deposit_amount") private BigDecimal depositAmount; + + @Column(name = "renewal_grace_period_days") + private Integer renewalGracePeriodDays; } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java index 9fa9fe453..04534c5ac 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/ProductPendingEditFieldMapper.java @@ -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 populateRecurringDepositFields(final JsonCommand command, final RecurringDepositEditFields f) { diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java index 979941229..11b836c37 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/DepositAccountWritePlatformService.java @@ -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 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 creditGapInterestIfPastGrace(Long fdAccountId); + SavingsAccountTransaction mandatorySavingsAccountDeposit(SavingsAccountTransactionDTO accountTransactionDTO); }