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
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:
+9
-2
@@ -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
|
||||
|
||||
+97
-6
@@ -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) {
|
||||
|
||||
+54
@@ -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) {
|
||||
|
||||
|
||||
+4
@@ -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);
|
||||
|
||||
+66
@@ -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);
|
||||
}
|
||||
}
|
||||
+96
@@ -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);
|
||||
}
|
||||
}
|
||||
+78
@@ -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);
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -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()
|
||||
|
||||
+28
@@ -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);
|
||||
|
||||
+25
-12
@@ -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
|
||||
|
||||
+205
-5
@@ -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());
|
||||
|
||||
+2
@@ -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"));
|
||||
|
||||
+6
-3
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
+36
@@ -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>
|
||||
+26
@@ -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>
|
||||
+27
@@ -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>
|
||||
+30
@@ -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>
|
||||
+4
@@ -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;
|
||||
|
||||
+16
-4
@@ -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) {
|
||||
|
||||
+27
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -68,4 +68,7 @@ public class FixedDepositEditFields {
|
||||
|
||||
@Column(name = "deposit_amount")
|
||||
private BigDecimal depositAmount;
|
||||
|
||||
@Column(name = "renewal_grace_period_days")
|
||||
private Integer renewalGracePeriodDays;
|
||||
}
|
||||
|
||||
+5
@@ -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) {
|
||||
|
||||
+22
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user