Merge branch 'origin/dev' into dev
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 Documentation build / build (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 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (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 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 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (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

This commit is contained in:
2026-08-25 11:32:55 +05:30
61 changed files with 2828 additions and 312 deletions
@@ -1263,6 +1263,11 @@ public class AccountingProcessorHelper {
accountMapping = paymentChannelSpecificAccountMapping;
}
}
if (accountMapping == null) {
throw new ProductToGLAccountMappingNotFoundException(PortfolioProductType.SAVING, savingsProductId,
CashAccountsForSavings.fromInt(accountMappingTypeId).toString());
}
glAccount = accountMapping.getGlAccount();
}
return glAccount;
@@ -357,9 +357,14 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
accountsSummary.append("curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ");
accountsSummary.append("curr.display_symbol as currencyDisplaySymbol, ");
accountsSummary.append("sa.product_id as productId, p.name as productName, p.short_name as shortProductName, ");
accountsSummary.append("sa.deposit_type_enum as depositType ");
accountsSummary.append("sa.deposit_type_enum as depositType, ");
// Renewal-chain identity for Fixed Deposits: the stable customer-facing FD number and
// this row's cycle within the chain, so a client's account list can collapse a renewed
// FD into one entry rather than listing every term as a separate account.
accountsSummary.append("dtp.fd_account_number as fdAccountNumber, dtp.renewal_cycle_no as renewalCycleNo ");
accountsSummary.append("from m_savings_account sa ");
accountsSummary.append("join m_savings_product as p on p.id = sa.product_id ");
accountsSummary.append("left join m_deposit_account_term_and_preclosure dtp on dtp.savings_account_id = sa.id ");
accountsSummary.append("join m_currency curr on curr.code = sa.currency_code ");
accountsSummary.append("left join m_appuser sbu on sbu.id = sa.submittedon_userid ");
accountsSummary.append("left join m_appuser rbu on rbu.id = sa.rejectedon_userid ");
@@ -453,9 +458,15 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
approvedByUsername, approvedByFirstname, approvedByLastname, activatedOnDate, activatedByUsername, activatedByFirstname,
activatedByLastname, closedOnDate, closedByUsername, closedByFirstname, closedByLastname);
// Null for a savings/current account and for an FD opened before this column existed;
// callers fall back to accountNo, so an FD that has never been renewed still reads as
// its own FD number.
final String fdAccountNumber = rs.getString("fdAccountNumber");
final Integer renewalCycleNo = JdbcSupport.getInteger(rs, "renewalCycleNo");
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, onHoldAmount, availableBalance, accountTypeData, timeline, depositTypeData, subStatus,
lastActiveTransactionDate);
lastActiveTransactionDate, fdAccountNumber, renewalCycleNo);
}
}
@@ -567,8 +578,10 @@ public class AccountDetailsReadPlatformServiceJpaRepositoryImpl implements Accou
final EnumOptionData depositTypeData = SavingsEnumerations.depositType(400);
// Current accounts have no deposit term, so no FD renewal chain to report.
return new SavingsAccountSummaryData(id, accountNo, externalId, productId, productName, shortProductName, status, currency,
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate);
accountBalance, onHoldFunds, null, accountBalance, accountTypeData, timeline, depositTypeData, subStatus, activatedOnDate,
null, null);
}
}
@@ -57,14 +57,23 @@ public class MobileLookupServiceImpl implements MobileLookupService {
"9752123456", "9752345678", "9752987654"
);
/** Demo numbers that always return DISCONNECTED (found on the MNRL) without calling the external API. */
private static final Set<String> DEMO_DISCONNECTED_NUMBERS = Set.of("9000000001", "9000000002");
/** Demo numbers that always return SUSPECTED_AND_DISCONNECTED without calling the external API. */
private static final Set<String> DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS = Set.of("9000000003");
private final String baseUrl;
private final boolean enabled;
private final RestTemplate restTemplate;
private final MobileLookupLogRepository logRepository;
private final JdbcTemplate jdbcTemplate;
public MobileLookupServiceImpl(@Value("${fineract.mobile-lookup.base-url}") String baseUrl,
@Value("${fineract.mobile-lookup.enabled:true}") boolean enabled,
MobileLookupLogRepository logRepository, JdbcTemplate jdbcTemplate) {
this.baseUrl = baseUrl;
this.enabled = enabled;
this.restTemplate = new RestTemplate();
this.logRepository = logRepository;
this.jdbcTemplate = jdbcTemplate;
@@ -103,6 +112,39 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return MobileLookupResult.clean(logId, trimmed);
}
// Demo numbers — return DISCONNECTED (on the MNRL) without calling the external API
if (isDemoDisconnectedNumber(trimmed)) {
LOG.info("Demo disconnected number detected: {}", trimmed);
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "DISCONNECTED", false, true,
"Number is DISCONNECTED — found in MNRL records", null, source, clientId);
return MobileLookupResult.disconnected(logId, trimmed, disconnectedDetail);
}
// Demo numbers — return SUSPECTED_AND_DISCONNECTED without calling the external API
if (isDemoSuspectedAndDisconnectedNumber(trimmed)) {
LOG.info("Demo suspected+disconnected number detected: {}", trimmed);
final MobileLookupDetailData suspectedDetail = MobileLookupDetailData.forSuspected(
"active", "Demo DIP suspected number", "DEMO");
final MobileLookupDetailData disconnectedDetail = MobileLookupDetailData.forDisconnected(
"Number revoked and listed on MNRL (demo data)", "DEMO");
final Long logId = saveLog(trimmed, "SUSPECTED_AND_DISCONNECTED", true, true,
"Number is SUSPECTED and DISCONNECTED — found in both DIP and MNRL records", null, source, clientId);
return MobileLookupResult.suspectedAndDisconnected(logId, trimmed, suspectedDetail, disconnectedDetail);
}
// Lookup switched off by config (fineract.mobile-lookup.enabled=false) -- skip the external
// call entirely rather than fail with a connection error. Logged distinctly from a genuine
// CLEAN result so it can never be mistaken for one in an audit trail.
if (!enabled) {
LOG.info("DIP/MNRL lookup is disabled by configuration — skipping check for {}", trimmed);
final Long logId = saveLog(trimmed, MobileLookupLog.STATUS_CLEAN, false, false,
"DIP/MNRL lookup is disabled by configuration — the number was not actually checked.",
"{\"provider\":\"LOOKUP_DISABLED_BY_CONFIG\"}", source, clientId);
return MobileLookupResult.clean(logId, trimmed);
}
final String url = baseUrl + "/api/v1/lookup/check?mobileNumber=" + trimmed;
LOG.info("Calling DIP/MNRL lookup API: {}", url);
@@ -402,6 +444,26 @@ public class MobileLookupServiceImpl implements MobileLookupService {
return false;
}
private boolean isDemoDisconnectedNumber(final String mobileNumber) {
if (DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private boolean isDemoSuspectedAndDisconnectedNumber(final String mobileNumber) {
if (DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber)) {
return true;
}
if (mobileNumber.startsWith("91") && mobileNumber.length() > 10) {
return DEMO_SUSPECTED_AND_DISCONNECTED_NUMBERS.contains(mobileNumber.substring(2));
}
return false;
}
private String getStringOrNull(JsonObject json, String key) {
JsonElement el = json.get(key);
return (el != null && !el.isJsonNull()) ? el.getAsString() : null;
@@ -18,6 +18,11 @@
*/
package org.apache.fineract.portfolio.savings.api;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_DESCRIPTION;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_FILE;
import static org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiConstants.DOCUMENT_API_PARAM_NAME;
import static org.springframework.http.HttpHeaders.CONTENT_LENGTH;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
@@ -26,6 +31,7 @@ import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
@@ -34,9 +40,11 @@ import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import java.io.InputStream;
import java.time.LocalDate;
import java.util.Collection;
import java.util.List;
@@ -50,6 +58,10 @@ import org.apache.fineract.infrastructure.core.exception.UnrecognizedQueryParamE
import org.apache.fineract.infrastructure.core.serialization.ApiRequestJsonSerializationSettings;
import org.apache.fineract.infrastructure.core.serialization.DefaultToApiJsonSerializer;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.documentmanagement.api.DocumentApiResource;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentCreateResponse;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentData;
import org.apache.fineract.infrastructure.documentmanagement.data.DocumentDeleteResponse;
import org.apache.fineract.infrastructure.security.exception.NoAuthorizationException;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.infrastructure.security.service.SqlValidator;
@@ -63,6 +75,9 @@ import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformS
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountEditWritePlatformService;
import org.apache.fineract.portfolio.currentaccount.service.CurrentAccountWritePlatformService;
import org.apache.fineract.portfolio.savings.service.CurrentAccountTemplateReadPlatformService;
import org.glassfish.jersey.media.multipart.FormDataBodyPart;
import org.glassfish.jersey.media.multipart.FormDataContentDisposition;
import org.glassfish.jersey.media.multipart.FormDataParam;
import org.springframework.stereotype.Component;
@Path("/v1/currentaccounts")
@@ -84,6 +99,10 @@ public class CurrentAccountsApiResource {
private final CurrentAccountEditWritePlatformService currentAccountEditWritePlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final DefaultToApiJsonSerializer<CurrentAccountPendingEditData> pendingEditApiJsonSerializer;
private final DocumentApiResource documentApiResource;
/** Matches the {@code entityType} current-account document requests are stored/looked up under. */
private static final String DOCUMENT_ENTITY_TYPE = "currentaccounts";
@GET
@Path("template")
@@ -337,4 +356,54 @@ public class CurrentAccountsApiResource {
return this.toApiJsonSerializer.serialize(result);
}
// ── Documents ────────────────────────────────────────────────────────────
// The generic /v1/{entityType}/{entityId}/documents resource (DocumentApiResource) never gets a
// chance to handle current-account requests: this class's own literal /v1/currentaccounts path
// is matched first for anything under {accountId}, and it has no "documents" method, so the
// request dead-ends in a 404 instead of falling through. These delegate straight to the same
// DocumentApiResource so current accounts get identical document-management behaviour.
@GET
@Path("{accountId}/documents")
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "List documents attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public List<DocumentData> retrieveDocuments(@PathParam("accountId") final Long accountId) {
return documentApiResource.retrieveAllDocuments(DOCUMENT_ENTITY_TYPE, accountId);
}
@GET
@Path("{accountId}/documents/{documentId}/attachment")
@Produces({ MediaType.APPLICATION_OCTET_STREAM })
@Operation(summary = "Download a document attached to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown: The corresponding Binary file")
public Response downloadDocument(@PathParam("accountId") final Long accountId, @PathParam("documentId") final Long documentId) {
return documentApiResource.downloadFile(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
@POST
@Path("{accountId}/documents")
@Consumes({ MediaType.MULTIPART_FORM_DATA })
@Produces({ MediaType.APPLICATION_JSON })
@Operation(summary = "Attach a document to a current account")
@ApiResponse(responseCode = "200", description = "Not Shown (multi-part form data)")
public DocumentCreateResponse createDocument(@PathParam("accountId") final Long accountId,
@HeaderParam(CONTENT_LENGTH) final Long fileSize, @FormDataParam(DOCUMENT_API_PARAM_FILE) final InputStream inputStream,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataContentDisposition fileDetails,
@FormDataParam(DOCUMENT_API_PARAM_FILE) final FormDataBodyPart filePart,
@FormDataParam(DOCUMENT_API_PARAM_NAME) final String name,
@FormDataParam(DOCUMENT_API_PARAM_DESCRIPTION) final String description) {
return documentApiResource.createDocument(DOCUMENT_ENTITY_TYPE, accountId, fileSize, inputStream, fileDetails, filePart, name,
description);
}
@DELETE
@Path("{accountId}/documents/{documentId}")
@Operation(summary = "Remove a document attached to a current account")
@ApiResponse(responseCode = "200", description = "OK")
public DocumentDeleteResponse deleteDocument(@PathParam("accountId") final Long accountId,
@PathParam("documentId") final Long documentId) {
return documentApiResource.deleteDocument(DOCUMENT_ENTITY_TYPE, accountId, documentId);
}
}
@@ -440,6 +440,9 @@ public class FixedDepositAccountsApiResource {
} else if (is(commandParam, "prematureClose")) {
final CommandWrapper commandRequest = builder.prematureCloseFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "payment")) {
final CommandWrapper commandRequest = builder.paymentFixedDepositAccount(accountId).build();
result = this.commandsSourceWritePlatformService.logCommandSource(commandRequest);
} else if (is(commandParam, "calculatePrematureAmount")) {
final JsonElement parsedQuery = this.fromJsonHelper.parse(apiRequestBodyAsJson);
final JsonQuery query = JsonQuery.from(apiRequestBodyAsJson, parsedQuery, this.fromJsonHelper);
@@ -453,7 +456,7 @@ public class FixedDepositAccountsApiResource {
if (result == null) {
throw new UnrecognizedQueryParamException("command", commandParam,
new Object[] { "reject", "withdrawnByApplicant", "approve", "undoapproval", "activate", "calculateInterest",
"postInterest", "close", "prematureClose", "calculatePrematureAmount" });
"postInterest", "close", "prematureClose", "payment", "calculatePrematureAmount" });
}
return this.toApiJsonSerializer.serialize(result);
@@ -489,6 +492,8 @@ public class FixedDepositAccountsApiResource {
DepositAccountData account = null;
if (is(commandParam, "close")) {
account = this.depositAccountReadPlatformService.retrieveOneWithClosureTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
} else if (is(commandParam, "payment")) {
account = this.depositAccountReadPlatformService.retrieveOneWithPaymentTemplate(DepositAccountType.FIXED_DEPOSIT, accountId);
}
final ApiRequestJsonSerializationSettings settings = this.apiRequestParameterHelper.process(uriInfo.getQueryParameters());
@@ -97,6 +97,7 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.domain.SavingsAccount;
import org.apache.fineract.portfolio.savings.domain.SavingsAccountRepositoryWrapper;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -105,11 +106,14 @@ public class DepositAccountDataValidator {
private final FromJsonHelper fromApiJsonHelper;
private final DepositProductDataValidator productDataValidator;
private final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper;
@Autowired
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator) {
public DepositAccountDataValidator(final FromJsonHelper fromApiJsonHelper, final DepositProductDataValidator productDataValidator,
final SavingsAccountRepositoryWrapper savingsAccountRepositoryWrapper) {
this.fromApiJsonHelper = fromApiJsonHelper;
this.productDataValidator = productDataValidator;
this.savingsAccountRepositoryWrapper = savingsAccountRepositoryWrapper;
}
public void validateFixedDepositForSubmit(final String json) {
@@ -351,10 +355,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
}
@@ -493,10 +527,40 @@ public class DepositAccountDataValidator {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).notNull()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
// Withdraw Deposit books the maturity proceeds straight out of the bank's cash/vault GL the
// moment the account matures -- before the customer has actually come in to collect it. For
// maturities processed automatically by the scheduled job (not a live counter transaction),
// that books cash as disbursed while it is still physically in the vault, so this option is
// disabled: every account must resolve to Transfer to Savings or a Reinvest option instead.
if (depositRolloverId.equals(DepositAccountOnClosureType.WITHDRAW_DEPOSIT.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.withdraw.deposit.not.allowed",
"Withdraw Deposit is not a valid maturity instruction -- choose Transfer to Savings or a Reinvest option.");
}
// Partial Reinvest needs a specific kept-amount decided at the moment of closure -- there is
// nothing sensible to pre-commit to years in advance as a standing instruction, so it is only
// ever chosen manually via the Payment action, never set here.
if (depositRolloverId.equals(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue())) {
baseDataValidator.reset().parameter(maturityInstructionIdParamName).value(depositRolloverId).failWithCodeAndMessage(
"maturity.instruction.partial.reinvest.not.allowed",
"Partial Re-Invest is not a valid standing maturity instruction -- it can only be chosen manually at closure time.");
}
if (depositRolloverId.equals(DepositAccountOnClosureType.TRANSFER_TO_SAVINGS.getValue())) {
final Long transferToSavingsId = this.fromApiJsonHelper.extractLongNamed(transferToSavingsIdParamName, element);
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId).notNull()
.longGreaterThanZero();
// Transfer to Savings can only ever post into a genuine Savings account -- Current accounts
// live in a separate ledger/module that this maturity-closure transfer cannot write into.
if (transferToSavingsId != null && transferToSavingsId > 0
&& !this.savingsAccountRepositoryWrapper.existsWithDepositAccountType(transferToSavingsId,
DepositAccountType.SAVINGS_DEPOSIT)) {
baseDataValidator.reset().parameter(transferToSavingsIdParamName).value(transferToSavingsId)
.failWithCodeAndMessage("transfer.to.savings.id.is.not.a.savings.account",
"The selected account is not a Savings account -- Transfer to Savings can only target a Savings account.");
}
}
}
@@ -82,6 +82,13 @@ public class DepositAccountTransactionDataValidator {
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.reinvestAmountParamName));
@Autowired
public DepositAccountTransactionDataValidator(final FromJsonHelper fromApiJsonHelper) {
this.fromApiJsonHelper = fromApiJsonHelper;
@@ -222,7 +229,15 @@ public class DepositAccountTransactionDataValidator {
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isTransferToSavings()) {
if (accountOnClosureType.isPartialReinvest()) {
// Partial Re-Invest needs a kept-amount and a destination account that only the Payment
// command collects, and only its handler implements. Neither the FD nor the RD closure
// handler has a branch for it, so without this it would fall through to their terminal
// "withdraw the whole balance" case and silently pay the deposit out in cash.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"partial.reinvest.not.allowed",
"Partial Re-Invest is only supported through the Payment action, not account closure");
} else if (accountOnClosureType.isTransferToSavings()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName,
@@ -247,6 +262,73 @@ public class DepositAccountTransactionDataValidator {
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
/**
* Validates the FD "Payment" command -- kept independent of {@link #validateClosing}, which stays exactly as-is
* for Close/Premature Close, so this never changes either of their existing behavior. Unlike
* {@code validateClosing}, reinvest is always allowed here (there is no {@code isPreMatureClose} restriction --
* Payment is meant to work on an Active account too), and a new Partial Reinvest closure type needs its own
* amount and destination-account rules.
*/
public void validatePayment(final JsonCommand command) {
final String json = command.json();
if (StringUtils.isBlank(json)) {
throw new InvalidJsonException();
}
final Type typeOfMap = new TypeToken<Map<String, Object>>() {}.getType();
this.fromApiJsonHelper.checkForUnsupportedParameters(typeOfMap, json, DEPOSIT_ACCOUNT_PAYMENT_REQUEST_DATA_PARAMETERS);
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(DepositAccountType.FIXED_DEPOSIT.resourceName());
final JsonElement element = command.parsedJson();
final LocalDate closedOnDate = this.fromApiJsonHelper.extractLocalDateNamed(closedOnDateParamName, element);
baseDataValidator.reset().parameter(closedOnDateParamName).value(closedOnDate).notNull();
final Integer onAccountClosureId = this.fromApiJsonHelper.extractIntegerSansLocaleNamed(onAccountClosureIdParamName, element);
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).notBlank()
.isOneOfTheseValues(DepositAccountOnClosureType.integerValues());
if (onAccountClosureId != null) {
final DepositAccountOnClosureType accountOnClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (accountOnClosureType.isWithdarwDeposit()) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCodeAndMessage(
"withdraw.deposit.not.allowed.for.payment",
"Withdraw Deposit is not supported via Payment -- choose Transfer to Savings or a Reinvest option.");
}
// Interest earned under Reinvest Principal Only has to leave the deposit somewhere -- the same
// destination-account rule Transfer to Savings and Partial Reinvest already need.
if (accountOnClosureType.isTransferToSavings() || accountOnClosureType.isPartialReinvest()
|| accountOnClosureType.isReinvestPrincipal()) {
final Long toSavingsAccountId = this.fromApiJsonHelper.extractLongNamed(toSavingsAccountIdParamName, element);
baseDataValidator.reset().parameter(toSavingsAccountIdParamName).value(toSavingsAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
}
if (accountOnClosureType.isPartialReinvest()) {
final BigDecimal reinvestAmount = this.fromApiJsonHelper
.extractBigDecimalWithLocaleNamed(DepositsApiConstants.reinvestAmountParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.reinvestAmountParamName).value(reinvestAmount).notNull()
.positiveAmount();
}
}
// Validate all string payment detail fields for max length
final Integer paymentTypeId = this.fromApiJsonHelper.extractIntegerWithLocaleNamed(paymentTypeIdParamName, element);
baseDataValidator.reset().parameter(paymentTypeIdParamName).value(paymentTypeId).ignoreIfNull().integerGreaterThanZero();
final Set<String> paymentDetailParameters = new HashSet<>(Arrays.asList(transactionAccountNumberParamName, checkNumberParamName,
routingCodeParamName, receiptNumberParamName, bankNumberParamName));
for (final String paymentDetailParameterName : paymentDetailParameters) {
final String paymentDetailParameterValue = this.fromApiJsonHelper.extractStringNamed(paymentDetailParameterName, element);
baseDataValidator.reset().parameter(paymentDetailParameterName).value(paymentDetailParameterValue).ignoreIfNull()
.notExceedingLengthOf(50);
}
throwExceptionIfValidationWarningsExist(dataValidationErrors);
}
private void throwExceptionIfValidationWarningsExist(final List<ApiParameterError> dataValidationErrors) {
if (!dataValidationErrors.isEmpty()) {
//
@@ -87,6 +87,7 @@ import org.apache.fineract.portfolio.group.exception.CenterNotActiveException;
import org.apache.fineract.portfolio.group.exception.ClientNotInGroupException;
import org.apache.fineract.portfolio.group.exception.GroupNotActiveException;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetailAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
@@ -97,9 +98,11 @@ import org.apache.fineract.portfolio.savings.SavingsInterestCalculationType;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsPostingInterestPeriodType;
import org.apache.fineract.portfolio.savings.data.SavingsAccountTransactionDTO;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.exception.FixedDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.RecurringDepositProductNotFoundException;
import org.apache.fineract.portfolio.savings.exception.SavingsProductNotFoundException;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@@ -123,6 +126,7 @@ public class DepositAccountAssembler {
private final ExternalIdFactory externalIdFactory;
private final SavingsAccountAssembler savingsAccountAssembler;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Autowired
public DepositAccountAssembler(final SavingsAccountTransactionSummaryWrapper savingsAccountTransactionSummaryWrapper,
@@ -134,7 +138,8 @@ public class DepositAccountAssembler {
final RecurringDepositProductRepository recurringDepositProductRepository,
final AccountTransfersReadPlatformService accountTransfersReadPlatformService, final PlatformSecurityContext context,
final PaymentDetailAssembler paymentDetailAssembler, ExternalIdFactory externalIdFactory,
final SavingsAccountAssembler savingsAccountAssembler) {
final SavingsAccountAssembler savingsAccountAssembler,
final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService) {
this.savingsAccountTransactionSummaryWrapper = savingsAccountTransactionSummaryWrapper;
this.clientRepository = clientRepository;
@@ -151,6 +156,7 @@ public class DepositAccountAssembler {
this.paymentDetailAssembler = paymentDetailAssembler;
this.externalIdFactory = externalIdFactory;
this.savingsAccountAssembler = savingsAccountAssembler;
this.fdBaseInterestRateReadPlatformService = fdBaseInterestRateReadPlatformService;
}
/**
@@ -322,6 +328,28 @@ public class DepositAccountAssembler {
accountChart = DepositAccountInterestRateChart.from(productChart);
}
// A new FD priced from its product's own chart never carries a senior-citizen premium -- that
// concept exists only on the CBS Base Interest Rate master. So whenever staff hasn't picked a
// specific chart, price the account from that master's currently active version instead (the same
// one the Base Interest Rates screen shows and renewals already use), so a senior-citizen FD shows
// its premium from day one rather than only after its first renewal. RD is unaffected: this only
// runs when depositAccountType.isFixedDeposit(), and it's a no-op wherever no active base-rate
// version exists for the product's FD type, in which case the product's own chart above stands.
if (depositAccountType.isFixedDeposit() && accountChart != null && !command.parameterExists(chartIdParamName)
&& product instanceof FixedDepositProduct) {
final String fdType = ((FixedDepositProduct) product).getFdType();
if (fdType != null) {
final FdInterestRateEffectiveData activeBaseRates = this.fdBaseInterestRateReadPlatformService
.retrieveActiveInterestRatesByType(fdType).stream().findFirst().orElse(null);
if (activeBaseRates != null && activeBaseRates.getSlabs() != null && !activeBaseRates.getSlabs().isEmpty()) {
final InterestRateChartFields baseChartFields = DepositAccountDomainServiceJpa.baseChartFieldsOf(activeBaseRates);
final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs = DepositAccountDomainServiceJpa
.baseChartSlabsOf(activeBaseRates, product.currency().getCode());
accountChart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
}
}
}
boolean withHoldTax = product.withHoldTax();
if (command.parameterExists(withHoldTaxParamName)) {
withHoldTax = command.booleanPrimitiveValueOfParameterNamed(withHoldTaxParamName);
@@ -35,6 +35,14 @@ public interface DepositAccountDomainService {
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail);
/**
* As above, but lets the caller declare the deposit as an internal account-to-account movement rather
* than cash taken over the counter. Used by reinvestment on maturity, where the funds come straight
* from the matured deposit and must post against the transfer-suspense GL instead of cash/vault.
*/
SavingsAccountTransaction handleFDDeposit(FixedDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isAccountTransfer);
SavingsAccountTransaction handleRDDeposit(RecurringDepositAccount account, DateTimeFormatter fmt, LocalDate transactionDate,
BigDecimal transactionAmount, PaymentDetail paymentDetail, boolean isRegularTransaction);
@@ -56,4 +64,7 @@ public interface DepositAccountDomainService {
Long handleRDAccountPreMatureClosure(RecurringDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
Long handleFDAccountPayment(FixedDepositAccount account, PaymentDetail paymentDetail, AppUser user, JsonCommand command,
Map<String, Object> changes);
}
@@ -19,6 +19,7 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.onAccountClosureIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.reinvestAmountParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavingsAccountIdParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
@@ -26,6 +27,9 @@ import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Locale;
@@ -55,12 +59,18 @@ import org.apache.fineract.portfolio.calendar.domain.CalendarInstanceRepository;
import org.apache.fineract.portfolio.calendar.domain.CalendarType;
import org.apache.fineract.portfolio.calendar.service.CalendarUtils;
import org.apache.fineract.portfolio.common.domain.PeriodFrequencyType;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartSlabFields;
import org.apache.fineract.portfolio.paymentdetail.domain.PaymentDetail;
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.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsPeriodFrequencyType;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
import org.apache.fineract.portfolio.savings.data.FdInterestRateEffectiveData;
import org.apache.fineract.portfolio.savings.data.FdInterestRateSlabData;
import org.apache.fineract.portfolio.savings.service.FdBaseInterestRateReadPlatformService;
import org.apache.fineract.portfolio.savings.service.SavingsAccountDomainService;
import org.apache.fineract.useradministration.domain.AppUser;
import org.springframework.stereotype.Service;
@@ -80,13 +90,28 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final AccountNumberFormatRepositoryWrapper accountNumberFormatRepository;
private final CalendarInstanceRepository calendarInstanceRepository;
private final ExternalIdFactory externalIdFactory;
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
@Transactional
@Override
public SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleWithdrawal(account, fmt, transactionDate, transactionAmount, paymentDetail, applyWithdrawFee, isRegularTransaction,
isAccountTransfer);
}
/**
* As above, but lets the caller declare the withdrawal as an internal account-to-account movement
* rather than cash paid out over the counter. Used by reinvestment on maturity, where the balance
* moves straight into the new deposit and must post against the transfer-suspense GL, not cash/vault.
*/
private SavingsAccountTransaction handleWithdrawal(final SavingsAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean applyWithdrawFee, final boolean isRegularTransaction, final boolean isAccountTransfer) {
boolean isInterestTransfer = false;
boolean isWithdrawBalance = false;
final boolean backdatedTxnsAllowedTill = false;
@@ -100,7 +125,15 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail) {
boolean isAccountTransfer = false;
final boolean isAccountTransfer = false;
return handleFDDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail, isAccountTransfer);
}
@Transactional
@Override
public SavingsAccountTransaction handleFDDeposit(final FixedDepositAccount account, final DateTimeFormatter fmt,
final LocalDate transactionDate, final BigDecimal transactionAmount, final PaymentDetail paymentDetail,
final boolean isAccountTransfer) {
boolean isRegularTransaction = false;
final boolean backdatedTxnsAllowedTill = false;
return this.savingsAccountDomainService.handleDeposit(account, fmt, transactionDate, transactionAmount, paymentDetail,
@@ -200,16 +233,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
// Captured before the withdrawal below empties the account, so the renewal history can
// record what this term actually matured at.
final BigDecimal maturedBalance = account.getAccountBalance();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(account.getAccountBalance(), externalId);
FixedDepositAccount reinvestedDeposit = account.reInvest(maturedBalance, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
// Renewal is an internal movement into the new deposit, not cash paid out -- post it
// against the transfer-suspense GL rather than crediting cash/vault.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, maturedBalance, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// Reached when a user closes the account with a re-invest instruction, rather than the
// maturity job acting on a standing instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
@@ -260,13 +303,58 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
account.setClosedOnDate(closedDate);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvest()) {
BigDecimal reInvestAmount;
if (onClosureType.isReinvestPrincipal()) {
reInvestAmount = account.getDepositAmount();
} else {
reInvestAmount = account.getAccountBalance();
}
if (onClosureType.isReinvestPrincipalAndInterest()) {
// Renews on the SAME account, so the customer keeps one account number however many times
// the deposit rolls over. Nothing moves: the whole matured balance simply becomes the new
// term's principal, so there is no withdrawal, no deposit and no journal entry for the
// rollover itself -- which also removes the transfer-suspense round trip the old
// close-and-reopen flow needed.
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
// The active CBS base interest-rate version for this FD type: the same rate card the Base
// Interest Rates screen shows and the account-opening wizard prices from. Used for both the
// new term's slabs and its senior-citizen premium, so screen and account agree.
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
// Captured before renewInPlace overwrites the account's chart, so the closing term's full
// rate-by-tenure chart (including its senior-citizen premiums) can be archived rather than lost
// -- renewInPlace mutates the same chart row every time, it does not create a new one.
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(maturedBalance, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, maturedBalance, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
} else if (onClosureType.isReinvest()) {
// "Re-invest principal only" still closes and reopens, because only the principal carries
// forward and the interest has to leave the deposit -- which needs a payout destination that
// 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();
ExternalId externalId = this.externalIdFactory.create();
FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
@@ -276,18 +364,26 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
// Renewal moves the balance internally into the new deposit -- no cash leaves the branch --
// so this leg is posted as an account transfer (against the transfer-suspense GL) rather
// than as a counter withdrawal, which would wrongly credit the cash/vault account.
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction);
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's own journal entries, so it has to be marked as
// already accounted for -- exactly as the transfer-to-savings branch below does -- or the
// postJournalEntries() call at the end of this method posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (onClosureType.isReinvestPrincipalAndInterest()) {
account.updateClosedStatus();
account.updateOnAccountClosureStatus(onClosureType);
}
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
// Reached from the maturity job processing the account's standing maturity instruction.
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_AUTO, user);
} else if (onClosureType.isTransferToSavings()) {
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
@@ -304,6 +400,7 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
// if(!processMaturityInstructionOnly)
@@ -429,6 +526,274 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
}
/**
* Persists the closing term's full rate-by-tenure chart -- each slab together with its senior-citizen premium --
* against its {@link FixedDepositRenewalHistory} row.
* <p>
* Slabs missing a rate or a from-period are skipped -- they cannot describe a tenure band. Writes nothing when the
* closing term had no chart (e.g. a plain flat-rate deposit), rather than persisting an empty, meaningless archive.
*/
private void archiveChartSlabs(final Long fdRenewalHistoryId, final String chartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots, final String currencyCode) {
if (snapshots == null || snapshots.isEmpty()) {
return;
}
for (final DepositAccountInterestRateChart.SlabSnapshot snapshot : snapshots) {
final InterestRateChartSlabFields fields = snapshot.getFields();
if (fields.annualInterestRate() == null || fields.fromPeriod() == null) {
continue;
}
this.fixedDepositRenewalChartSlabHistoryRepository
.save(FixedDepositRenewalChartSlabHistory.create(fdRenewalHistoryId, chartName, fields.description(),
fields.periodType(), fields.fromPeriod(), fields.toPeriod(), fields.getAmountRangeFrom(),
fields.getAmountRangeTo(), fields.annualInterestRate(), currencyCode, snapshot.getSeniorCitizenRate()));
}
}
/**
* Chart identity for the account snapshot, so it names the base rate version it was priced from. Package-private
* and static (no instance state used) so {@link DepositAccountAssembler} can reuse the exact same mapping when a
* new FD account is first priced from this master, instead of duplicating the logic.
*/
static InterestRateChartFields baseChartFieldsOf(final FdInterestRateEffectiveData baseRates) {
// name and from_date are NOT NULL on the embeddable. A base version missing either would abort the
// whole renewal (or account creation) with a constraint violation, so treat it as "no identity to
// apply" instead: refreshFromSlabSnapshots keeps the chart's existing fields when handed null, and
// the slabs -- the part that actually prices the deposit -- still get applied.
if (baseRates == null || baseRates.getVersionLabel() == null || baseRates.getEffectiveFromDate() == null) {
return null;
}
final boolean isPrimaryGroupingByAmount = false; // the base rate card bands by tenure, never by amount
return InterestRateChartFields.createNew(baseRates.getVersionLabel(), baseRates.getRemarks(), baseRates.getEffectiveFromDate(),
baseRates.getEffectiveToDate(), isPrimaryGroupingByAmount);
}
/**
* Translates the base rate card's slabs into the shape an account chart stores, each paired with its senior-citizen
* premium so that figure is persisted on the account (and later archived) rather than only ever being looked up live.
* <p>
* The base master bands purely by tenure, so amount ranges are left unset -- which
* {@code InterestRateChartSlabFields.isAmountBetween} then treats as "any amount", exactly the intent. Slabs with no
* from-period are dropped, since a slab that does not say where it starts cannot be matched against a tenure.
*/
static Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabsOf(final FdInterestRateEffectiveData baseRates,
final String currencyCode) {
if (baseRates == null || baseRates.getSlabs() == null) {
return Collections.emptyList();
}
final List<DepositAccountInterestRateChart.SlabSnapshot> snapshots = new ArrayList<>();
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (slab.getFromPeriod() == null || slab.getAnnualInterestRate() == null) {
continue;
}
final InterestRateChartSlabFields fields = InterestRateChartSlabFields.createNew(slab.getDescription(),
SavingsPeriodFrequencyType.fromInt(slab.getPeriodTypeEnum()), slab.getFromPeriod(), slab.getToPeriod(), null, null,
slab.getAnnualInterestRate(), currencyCode);
snapshots.add(new DepositAccountInterestRateChart.SlabSnapshot(fields, slab.getSeniorCitizenRate()));
}
return snapshots;
}
/**
* The senior-citizen premium from the active base rate card for a given tenure.
* <p>
* The tenure and the slab bounds are converted to a common unit before comparing, so a rate card published in years
* still matches a deposit taken for a number of months. Conversion stays inside a unit family -- days/weeks, or
* months/years -- because only those are exact; a days-based card is therefore still never matched against a
* months-based tenure, since "1 month" is not a fixed number of days and guessing would land the deposit in the
* wrong band. Null when no slab covers the tenure or the matching slab carries no premium, which leaves the
* account's existing rate untouched rather than silently dropping the depositor's premium.
*/
private BigDecimal seniorCitizenRateFor(final FdInterestRateEffectiveData baseRates, final Integer tenure,
final Integer tenurePeriodTypeEnum) {
if (baseRates == null || baseRates.getSlabs() == null || tenure == null) {
return null;
}
final Integer tenureInBaseUnit = inFamilyBaseUnit(tenure, tenurePeriodTypeEnum);
if (tenureInBaseUnit == null) {
return null;
}
for (final FdInterestRateSlabData slab : baseRates.getSlabs()) {
if (!sameUnitFamily(tenurePeriodTypeEnum, slab.getPeriodTypeEnum())) {
continue;
}
final Integer slabFrom = inFamilyBaseUnit(slab.getFromPeriod(), slab.getPeriodTypeEnum());
if (slabFrom == null || tenureInBaseUnit < slabFrom) {
continue;
}
if (slab.getToPeriod() != null) {
final Integer slabTo = inFamilyBaseUnit(slab.getToPeriod(), slab.getPeriodTypeEnum());
if (slabTo == null || tenureInBaseUnit > slabTo) {
continue;
}
}
final BigDecimal rate = slab.getSeniorCitizenRate();
if (rate != null && rate.compareTo(BigDecimal.ZERO) > 0) {
return rate;
}
}
return null;
}
/**
* {@code value} restated in the finest unit of its own family -- days for DAYS/WEEKS, months for MONTHS/YEARS -- so
* two periods written in different but related units can be compared without losing precision. Null for an unknown
* unit. Enum codes are {@link SavingsPeriodFrequencyType}: DAYS(0), WEEKS(1), MONTHS(2), YEARS(3).
*/
private Integer inFamilyBaseUnit(final Integer value, final Integer periodTypeEnum) {
if (value == null || periodTypeEnum == null) {
return null;
}
return switch (SavingsPeriodFrequencyType.fromInt(periodTypeEnum)) {
case DAYS, MONTHS -> value;
case WEEKS -> value * 7;
case YEARS -> value * 12;
default -> null;
};
}
/**
* Whether two period types convert exactly into one another. Days and weeks do; months and years do. Across those
* two families they do not, because a month is not a fixed number of days -- so a days-based rate card is never
* matched against a months-based tenure rather than guessing and landing the deposit in the wrong band.
*/
private boolean sameUnitFamily(final Integer firstPeriodTypeEnum, final Integer secondPeriodTypeEnum) {
if (firstPeriodTypeEnum == null || secondPeriodTypeEnum == null) {
return false;
}
return isDayFamily(firstPeriodTypeEnum) == isDayFamily(secondPeriodTypeEnum);
}
private boolean isDayFamily(final Integer periodTypeEnum) {
final SavingsPeriodFrequencyType type = SavingsPeriodFrequencyType.fromInt(periodTypeEnum);
return type == SavingsPeriodFrequencyType.DAYS || type == SavingsPeriodFrequencyType.WEEKS;
}
/**
* Records a renewal that happened on the SAME account, where there is no second account row to describe the new term.
* <p>
* Called after {@link FixedDepositAccount#renewInPlace}, which has already advanced the account's cycle number,
* principal and maturity date -- so the closing term's figures have to be passed in rather than read back off the
* account, where they no longer exist. Every row for one FD shares that FD's single account id and account number;
* they are told apart by renewal_no, which is what makes this a one-to-many history of one account.
*
* @param closingChartName
* the name of the chart the closing term was priced on, captured before renewInPlace overwrote it; null if
* unavailable
* @param closingChartSlabs
* the closing term's full rate-by-tenure chart, likewise captured before it was overwritten. Archived
* against the closing term's row so its whole rate card remains queryable, not only the single effective
* rate {@link FixedDepositRenewalHistory} records.
* @param closingInterestRate
* the closing term's own {@code nominalAnnualInterestRate}, captured before renewInPlace overwrote it --
* needed only when this term has no existing row yet (see below), since the account itself no longer
* carries it once renewInPlace has run.
* @param closingTenure
* the closing term's own deposit period, captured the same way and needed for the same reason.
* @param closingTenureFrequencyEnum
* the closing term's own deposit period frequency, captured the same way and needed for the same reason.
*/
private void recordInPlaceRenewal(final FixedDepositAccount account, final BigDecimal closingPrincipal,
final BigDecimal closingInterestEarned, final BigDecimal maturedBalance, final BigDecimal renewedAmount,
final LocalDate renewedOn, final String renewalType, final AppUser user, final String closingChartName,
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs, final BigDecimal closingInterestRate,
final Integer closingTenure, final Integer closingTenureFrequencyEnum) {
// The CHAIN's number, not this account's own. They differ for an FD that was renewed under the old
// close-and-reopen model before renewing in place: its rows are keyed on the original account's
// number, so keying new rows on this account's number would split the history in two and re-create
// a duplicate row for the term that already has one. fdAccountNumber() falls back to this account's
// own number when there is no chain, which is what the read side resolves to as well.
final String fdAccountNumber = account.fdAccountNumber();
final int newCycleNo = account.renewalCycleNo();
final Long createdById = user == null ? null : user.getId();
final List<FixedDepositRenewalHistory> existingRows = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber);
FixedDepositRenewalHistory closingRow = existingRows.stream()
.filter(row -> Integer.valueOf(newCycleNo - 1).equals(row.getRenewalNo())).findFirst().orElse(null);
// The term that just closed has no row when this FD renews for the first time since the history
// table existed. Written now, from the figures captured before the rollover, so the history
// always begins with the term the customer originally opened -- rate and tenure included, rather
// than left blank, since the caller captured them before renewInPlace overwrote the account with
// the new term's own values.
if (closingRow == null) {
closingRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
account.getId(), account.getId(), null, null, newCycleNo - 1, closingPrincipal, null, maturedBalance,
closingPrincipal, closingInterestRate, account.getActivationDate(), renewedOn, closingTenure,
closingTenureFrequencyEnum, renewalType, account.getActivationDate(), createdById));
}
closingRow.recordOutcome(closingInterestEarned, maturedBalance);
closingRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(closingRow);
archiveChartSlabs(closingRow.getId(), closingChartName, closingChartSlabs, account.getCurrency().getCode());
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber, account.getId(),
account.getId(), null, closingRow.getId(), newCycleNo, renewedAmount, null, null, renewedAmount,
account.getNominalAnnualInterestRate(), renewedOn, account.maturityDate(), account.depositPeriod(),
account.depositPeriodFrequency(), renewalType, renewedOn, createdById));
}
/**
* Records the renewal that started {@code newTerm}, and closes off the previous term's history row.
* <p>
* Each term of an FD runs as its own deposit instance so it keeps an immutable ledger, which means the rollover event
* itself -- what the previous term earned, what carried forward, at what rate, automatically or by whom -- is not
* recoverable from the account rows alone. This writes it down explicitly.
* <p>
* The chain's first term is backfilled here too when it has no row yet: an FD opened before this table existed only
* gets discovered at its first renewal, and without its opening row the history would start at "Renewal 1" with no
* original.
*/
private void recordRenewal(final FixedDepositAccount previousTerm, final FixedDepositAccount newTerm,
final BigDecimal maturityAmount, final BigDecimal renewedAmount, final LocalDate renewedOn, final String renewalType,
final AppUser user) {
final String fdAccountNumber = previousTerm.fdAccountNumber();
final Long originalAccountId = this.fixedDepositRenewalHistoryRepository
.findByFdAccountNumberOrderByRenewalNoAsc(fdAccountNumber).stream().findFirst()
.map(FixedDepositRenewalHistory::getOriginalSavingsAccountId)
.orElseGet(() -> previousTerm.reinvestedFromSavingsId() == null ? previousTerm.getId() : null);
FixedDepositRenewalHistory previousRow = this.fixedDepositRenewalHistoryRepository
.findBySavingsAccountIdOrderByRenewalNoDesc(previousTerm.getId()).stream().findFirst().orElse(null);
// Backfill the opening term when the chain started before this table existed, so the history
// always begins with the account the customer actually opened.
if (previousRow == null && originalAccountId != null && originalAccountId.equals(previousTerm.getId())) {
previousRow = this.fixedDepositRenewalHistoryRepository.saveAndFlush(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId, previousTerm.getId(), null, null, previousTerm.renewalCycleNo(),
previousTerm.getDepositAmount(), null, previousTerm.maturityAmount(), previousTerm.getDepositAmount(),
previousTerm.getNominalAnnualInterestRate(), previousTerm.getActivationDate(), previousTerm.maturityDate(),
previousTerm.depositPeriod(), previousTerm.depositPeriodFrequency(), renewalType, previousTerm.getActivationDate(),
user == null ? null : user.getId()));
}
// What the closing term actually earned. maturityAmount is passed in rather than read off the
// account because the caller has already withdrawn the balance out of it by this point, which
// would make it read as zero. Recorded on the CLOSING term's own row -- every row states its own
// term's outcome, so a reader never has to look at the next row to find out how one term did.
final BigDecimal previousPrincipal = previousTerm.getDepositAmount();
final BigDecimal interestEarned = maturityAmount == null || previousPrincipal == null ? null
: maturityAmount.subtract(previousPrincipal);
if (previousRow != null) {
previousRow.recordOutcome(interestEarned, maturityAmount);
previousRow.markMatured();
this.fixedDepositRenewalHistoryRepository.save(previousRow);
}
// The new term's own interest/maturity stay unset until it closes in turn.
this.fixedDepositRenewalHistoryRepository.save(FixedDepositRenewalHistory.create(fdAccountNumber,
originalAccountId == null ? previousTerm.getId() : originalAccountId, newTerm.getId(), previousTerm.getId(),
previousRow == null ? null : previousRow.getId(), newTerm.renewalCycleNo(), renewedAmount, null, null, renewedAmount,
newTerm.getNominalAnnualInterestRate(), renewedOn, newTerm.maturityDate(), newTerm.depositPeriod(),
newTerm.depositPeriodFrequency(), renewalType, renewedOn, user == null ? null : user.getId()));
}
@Transactional
@Override
public Long handleFDAccountPreMatureClosure(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
@@ -474,6 +839,9 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
// handleWithdrawal() posts this withdrawal's journal entries itself, so mark it as already
// accounted for or the postJournalEntries() call below posts it a second time.
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
account.prematureClosure(user, command, changes);
@@ -484,6 +852,238 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* A staff-triggered, always-available alternative to Premature Close (Active-only, no reinvest) and Close
* (Matured-only): works on an FD in either state, and adds a Partial Reinvest option neither of those two support
* -- part of the matured amount renews on this same account via {@link FixedDepositAccount#renewInPlace}, the rest
* transfers out to a savings account picked at the moment of the request. Full reinvest here also goes through
* {@code renewInPlace} rather than the legacy close-and-reopen {@link FixedDepositAccount#reInvest}, which is what
* the existing manual Close command still uses today.
* <p>
* Deliberately independent of {@link #handleFDAccountClosure} and {@link #handleFDAccountPreMatureClosure} --
* neither of those two methods is touched, so the existing Close/Premature Close buttons keep behaving exactly as
* before.
*/
@Transactional
@Override
public Long handleFDAccountPayment(final FixedDepositAccount account, final PaymentDetail paymentDetail, final AppUser user,
final JsonCommand command, final Map<String, Object> changes) {
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
boolean isRegularTransaction = false;
boolean isAccountTransfer = false;
final boolean isPreMatureClosure = false;
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
final MathContext mc = MathContext.DECIMAL64;
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
final LocalDate closedDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
Long savingsTransactionId = null;
// account.isMatured() only reflects whether the overnight scheduler has already flipped this
// account's status -- not whether the customer actually showed up before or after the maturity
// date. A customer walking in ON the maturity date (or any day after) is not premature just
// because that job hasn't run yet today, so the real test is the date, not the status flag.
if (!account.isMatured()) {
if (!DateUtils.isBefore(closedDate, account.maturityDate())) {
// Genuinely due, the scheduler just hasn't caught up yet -- do exactly what it would:
// flip to Matured and post final interest at the full contracted rate, no penalty.
account.updateMaturityStatus(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
} else {
// The three closing closure types re-check this themselves inside prematureClosure()
// below, but the two reinvest branches never call it (they keep the account open on the
// same term), so it is enforced once, up front, for every genuinely-premature Payment --
// a locked-in FD (e.g. a Tax Saver with its statutory lock-in) cannot be touched early via
// this action either.
if (account.isLockedOnDate(closedDate)) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.lockin.period.not.elapsed",
"This account is still within its lock-in period and cannot be processed before it elapses.", closedDate);
}
// Same penalty-aware interest calculation prematureClosure() already relies on, so a
// genuinely early Payment settles interest exactly as a Premature Close would.
account.postPreMaturityInterest(closedDate, true, isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
} else {
account.postMaturityInterest(isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
// Same as the closure and maturity paths do: the legacy reinvest branch's reInvest() reads
// getClosedOnDate() to pick the new account's chart and submitted date, so it has to be set
// before branching. renewInPlace() clears it again for the branches that keep the account open.
account.setClosedOnDate(closedDate);
// Re-read after the block above: updateMaturityStatus() may just have flipped it, so this now
// correctly decides close() (needs Matured) vs prematureClosure() (needs Active) either way.
final boolean alreadyMatured = account.isMatured();
final Integer onAccountClosureId = command.integerValueOfParameterNamed(onAccountClosureIdParamName);
final DepositAccountOnClosureType onClosureType = DepositAccountOnClosureType.fromInt(onAccountClosureId);
if (onClosureType.isReinvestPrincipalAndInterest() || onClosureType.isPartialReinvest()) {
// These two branches keep the account open, so they never reach close()/prematureClosure() and
// would otherwise accept any date at all -- including one behind already-posted interest, which
// the renewal would then turn into a pivot that reverses and re-posts it.
account.validateRenewalDate(closedDate);
final BigDecimal closingPrincipal = account.getDepositAmount();
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal interestEarned = maturedBalance == null || closingPrincipal == null ? null
: maturedBalance.subtract(closingPrincipal);
final BigDecimal renewedAmount;
if (onClosureType.isPartialReinvest()) {
renewedAmount = command.bigDecimalValueOfParameterNamed(reinvestAmountParamName);
if (renewedAmount == null || renewedAmount.compareTo(BigDecimal.ZERO) <= 0
|| renewedAmount.compareTo(maturedBalance) >= 0) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.partial.reinvest.amount.invalid",
"The reinvest amount must be greater than zero and less than the matured balance.", renewedAmount);
}
} else {
renewedAmount = maturedBalance;
}
final FdInterestRateEffectiveData activeBaseRates = account.fdType() == null ? null
: this.fdBaseInterestRateReadPlatformService.retrieveActiveInterestRatesByType(account.fdType()).stream().findFirst()
.orElse(null);
final BigDecimal currentSeniorCitizenRate = account.isSeniorCitizen()
? seniorCitizenRateFor(activeBaseRates, account.depositPeriod(), account.depositPeriodFrequency())
: null;
final String closingChartName = account.currentChartName();
final List<DepositAccountInterestRateChart.SlabSnapshot> closingChartSlabs = account.currentChartSlabFields();
// Likewise captured before renewInPlace overwrites the account's own rate/tenure with the new
// term's -- needed only if this is the closing term's first-ever history row (see
// recordInPlaceRenewal), so that row records what this term actually ran at instead of blank.
final BigDecimal closingInterestRate = account.getNominalAnnualInterestRate();
final Integer closingTenure = account.depositPeriod();
final Integer closingTenureFrequencyEnum = account.depositPeriodFrequency();
account.renewInPlace(renewedAmount, closedDate, currentSeniorCitizenRate, baseChartFieldsOf(activeBaseRates),
baseChartSlabsOf(activeBaseRates, account.getCurrency().getCode()));
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingsAccountRepository.save(account);
recordInPlaceRenewal(account, closingPrincipal, interestEarned, maturedBalance, renewedAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user, closingChartName, closingChartSlabs, closingInterestRate,
closingTenure, closingTenureFrequencyEnum);
changes.put("renewedInPlace", Boolean.TRUE);
changes.put("renewalCycleNo", account.renewalCycleNo());
if (onClosureType.isPartialReinvest()) {
// The kept portion never left the account -- renewInPlace() just relabels it as the new
// term's principal -- so only the remainder is an actual money movement, posted the same
// way the Transfer to Savings branch below posts its full-balance transfer.
final BigDecimal remainder = maturedBalance.subtract(renewedAmount);
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, remainder, PortfolioAccountType.SAVINGS,
PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null, null, null, null,
AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null, toSavingsAccount, account,
isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
} else if (onClosureType.isReinvestPrincipal()) {
// Legacy close-and-reopen, same shape as handleFDAccountClosure()'s isReinvest() branch --
// the interest has to leave the deposit, which needs a payout destination an in-place renewal
// has nowhere to send.
final BigDecimal maturedBalance = account.getAccountBalance();
final BigDecimal reInvestAmount = account.getDepositAmount();
final ExternalId externalId = this.externalIdFactory.create();
final FixedDepositAccount reinvestedDeposit = account.reInvest(reInvestAmount, externalId);
this.depositAccountAssembler.assignSavingAccountHelpers(reinvestedDeposit);
reinvestedDeposit.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
this.savingsAccountRepository.saveAndFlush(reinvestedDeposit);
autoGenerateAccountNumber(reinvestedDeposit);
final boolean isInternalTransferLeg = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, reInvestAmount, paymentDetail,
false, isRegularTransaction, isInternalTransferLeg);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
changes.put("reinvestedDepositId", reinvestedDeposit.getId());
reinvestedDeposit.approveAndActivateApplication(closedDate, user);
this.savingsAccountRepository.save(reinvestedDeposit);
recordRenewal(account, reinvestedDeposit, maturedBalance, reInvestAmount, closedDate,
FixedDepositRenewalHistory.RENEWAL_TYPE_MANUAL, user);
// Only the principal moved into the new deposit -- the interest earned on this term is still
// sitting in this account's balance. Pay it out to the chosen savings account now, before
// closing, rather than stranding it here with nowhere left to go.
final BigDecimal interestRemainder = maturedBalance.subtract(reInvestAmount);
if (interestRemainder.compareTo(BigDecimal.ZERO) > 0) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, interestRemainder,
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null,
null, null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
}
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else if (onClosureType.isTransferToSavings()) {
final Long toSavingsId = command.longValueOfParameterNamed(toSavingsAccountIdParamName);
final String transferDescription = command.stringValueOfParameterNamed(transferDescriptionParamName);
final SavingsAccount toSavingsAccount = this.depositAccountAssembler.assembleFrom(toSavingsId,
DepositAccountType.SAVINGS_DEPOSIT);
final boolean isExceptionForBalanceCheck = false;
final AccountTransferDTO accountTransferDTO = new AccountTransferDTO(closedDate, account.getAccountBalance(),
PortfolioAccountType.SAVINGS, PortfolioAccountType.SAVINGS, null, null, transferDescription, locale, fmt, null, null,
null, null, null, AccountTransferType.ACCOUNT_TRANSFER.getValue(), null, null, ExternalId.empty(), null,
toSavingsAccount, account, isAccountTransfer, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
if (alreadyMatured) {
account.close(user, command, changes);
} else {
account.prematureClosure(user, command, changes);
}
}
this.savingsAccountRepository.save(account);
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, isAccountTransfer);
return savingsTransactionId;
}
@Transactional
@Override
public Long handleRDAccountPreMatureClosure(final RecurringDepositAccount account, final PaymentDetail paymentDetail,
@@ -555,14 +1155,19 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
}
private void updateAlreadyPostedTransactions(final Set<Long> existingTransactionIds, final SavingsAccount savingsAccount) {
List<SavingsAccountTransaction> transactions = savingsAccount.getTransactions();
int size = transactions.size();
for (int i = size - 1;; i--) {
SavingsAccountTransaction transaction = transactions.get(i);
if (transaction.isWithdrawal() || transaction.isWithdrawalFee()) {
// accountTransfersWritePlatformService.transferFunds() already posts accounting for the
// withdrawal it creates on this account (via SavingsAccountDomainServiceJpa#handleWithdrawal),
// so that withdrawal must be added to existingTransactionIds here or the caller's own
// postJournalEntries() re-derives and re-posts it a second time. The previous implementation
// found it by walking the transaction list backwards from the end while the type stayed
// withdrawal/withdrawal-fee -- but @OneToMany collections only guarantee @OrderBy ordering on
// a fresh fetch, not after an in-memory append, so a same-day interest posting or any other
// ordering quirk could put the new withdrawal anywhere in the list and break the walk
// silently. Scanning every transaction and checking against the original snapshot is correct
// regardless of collection order.
for (final SavingsAccountTransaction transaction : savingsAccount.getTransactions()) {
if ((transaction.isWithdrawal() || transaction.isWithdrawalFee()) && !existingTransactionIds.contains(transaction.getId())) {
existingTransactionIds.add(transaction.getId());
} else {
break;
}
}
}
@@ -53,6 +53,7 @@ import org.apache.fineract.portfolio.accountdetails.domain.AccountType;
import org.apache.fineract.portfolio.client.domain.Client;
import org.apache.fineract.portfolio.group.domain.Group;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChart;
import org.apache.fineract.portfolio.interestratechart.domain.InterestRateChartFields;
import org.apache.fineract.portfolio.interestratechart.service.InterestRateChartAssembler;
import org.apache.fineract.portfolio.savings.DepositAccountOnClosureType;
import org.apache.fineract.portfolio.savings.DepositAccountType;
@@ -276,7 +277,9 @@ public class FixedDepositAccount extends SavingsAccount {
public LocalDate calculateMaturityDate() {
final LocalDate startDate = accountSubmittedOrActivationDate();
// The CURRENT term's start, not the account's activation date -- on a renewed FD these differ,
// and measuring the term from activation would put maturity in the past.
final LocalDate startDate = depositStartDate();
LocalDate maturityDate = null;
final Integer depositPeriod = this.accountTermAndPreClosure.depositPeriod();
switch (this.accountTermAndPreClosure.depositPeriodFrequencyType()) {
@@ -312,13 +315,19 @@ public class FixedDepositAccount extends SavingsAccount {
.fromInt(this.interestCalculationDaysInYearType);
List<LocalDate> postedAsOnTransactionDates = getManualPostingDates();
// Scoped to the current term, so a renewed FD's projection covers only the term now running --
// matching what postInterest() will actually post -- rather than replaying the whole account.
final List<LocalDateInterval> postingPeriodIntervals = this.savingsHelper.determineInterestPostingPeriods(
accountSubmittedOrActivationDate(), maturityDate, postingPeriodType, financialYearBeginningMonth,
postedAsOnTransactionDates);
depositStartDate(), maturityDate, postingPeriodType, financialYearBeginningMonth, postedAsOnTransactionDates);
final List<PostingPeriod> allPostingPeriods = new ArrayList<>();
Money periodStartingBalance = Money.zero(currency);
// On a first term the opening deposit falls inside the first posting period, so the period can
// start from zero and pick the balance up from that transaction. On a renewed term that deposit
// is BEFORE the period range and would be missed, leaving the term to earn interest on nothing,
// so the term's own principal seeds the opening balance instead.
Money periodStartingBalance = isRenewedTerm() ? Money.of(currency, this.accountTermAndPreClosure.depositAmount())
: Money.zero(currency);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final BigDecimal interestRateAsFraction = getEffectiveInterestRateAsFraction(mc, maturityDate, isPreMatureClosure);
@@ -435,7 +444,10 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.accountTermAndPreClosure.depositAmount());
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of the
// deposit amount configured on the account's term/pre-closure details.
return Money.zero(this.currency);
}
public void close(final AppUser currentUser, final JsonCommand command, final Map<String, Object> actualChanges) {
@@ -683,6 +695,60 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.maturityAmount();
}
/**
* The stable customer-facing FD number: the account number of the first account in this renewal chain. Falls back to
* this account's own number when it has never been renewed, so callers always get a usable FD number.
*/
public String fdAccountNumber() {
final String carried = this.accountTermAndPreClosure.getFdAccountNumber();
return carried != null ? carried : getAccountNumber();
}
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
public Integer renewalCycleNo() {
return this.accountTermAndPreClosure.getRenewalCycleNo();
}
public Integer depositPeriod() {
return this.accountTermAndPreClosure.depositPeriod();
}
public Integer depositPeriodFrequency() {
return this.accountTermAndPreClosure.depositPeriodFrequency();
}
public Long reinvestedFromSavingsId() {
return this.accountTermAndPreClosure.getReinvestedFromSavingsId();
}
/**
* The name of the chart currently priced on this account. Read before a renewal overwrites it, so the closing term's
* chart identity can be archived rather than lost.
*/
public String currentChartName() {
return this.chart == null ? null : this.chart.chartFields().getName();
}
/**
* The slabs of the chart currently priced on this account -- each paired with its senior-citizen premium -- detached
* from the chart's own persistence lifecycle.
* <p>
* Read before a renewal overwrites the account's chart in place ({@link #renewInPlace}), so the closing term's full
* rate-by-tenure breakdown (including the premium) can be archived -- not just the single effective rate the renewal
* history records -- rather than being silently deleted when the new term's slabs replace it.
*/
public List<DepositAccountInterestRateChart.SlabSnapshot> currentChartSlabFields() {
return this.chart == null ? java.util.Collections.emptyList() : this.chart.currentSlabSnapshots();
}
/**
* This deposit's FD type (SINGLE / DOUBLE / SPECIAL), which lives on the product rather than the account. Null when
* the product is not a Fixed Deposit product or has no type set.
*/
public String fdType() {
return this.product instanceof FixedDepositProduct ? ((FixedDepositProduct) this.product).getFdType() : null;
}
private Money totalInterestPosted() {
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
@@ -712,11 +778,26 @@ public class FixedDepositAccount extends SavingsAccount {
return actualChanges;
}
/**
* The start of the term currently running, which is what the deposit's tenure and rate must be measured from.
* <p>
* For an FD that has never been renewed this is the activation date, exactly as before. A renewal advances
* startInterestCalculationDate to the renewal date, so from then on the interest-rate chart slab is chosen by THIS
* term's tenure rather than the cumulative tenure since the account was opened, and maturity is measured from the
* renewal rather than from the original activation.
*/
private LocalDate depositStartDate() {
// TODO: Support to add deposit start date which can be a date after
// account activation date.
final LocalDate depositStartDate = accountSubmittedOrActivationDate();
return depositStartDate;
final LocalDate currentTermStartDate = getStartInterestCalculationDate();
return currentTermStartDate != null ? currentTermStartDate : accountSubmittedOrActivationDate();
}
/**
* True once this FD has been renewed at least once, i.e. the term now running started later than the account did.
* Everything keyed off this keeps its original, pre-renewal behaviour for an FD still on its first term.
*/
private boolean isRenewedTerm() {
final LocalDate termStart = getStartInterestCalculationDate();
return termStart != null && getActivationDate() != null && !termStart.equals(getActivationDate());
}
private LocalDate interestCalculatedUpto() {
@@ -811,28 +892,142 @@ public class FixedDepositAccount extends SavingsAccount {
return this.accountTermAndPreClosure.getTransferToSavingsAccountId();
}
/**
* Rolls this FD into a new term on the SAME account, so the customer keeps one account number however many times it
* renews. No new account is opened and no money moves: the balance simply carries into the next term.
* <p>
* What changes is the term itself. startInterestCalculationDate advances to the renewal date, which is what confines
* interest posting to the new term and thereby leaves the closing term's postings untouched -- they fall outside the
* posting periods the engine now generates, so nothing recalculates or reverses them. The term's principal becomes
* {@code renewedAmount}, and every interest-mechanics setting plus the rate chart is re-taken from the product as it
* stands today, so a renewal is priced on current terms rather than the ones frozen at account opening.
* <p>
* The caller must have posted the closing term's interest first, and must record the renewal in the FD's renewal
* history so each term's own figures remain reportable.
*
* @param renewedAmount
* what carries into the new term: the full maturity amount, or the principal only
* @param renewedOn
* the renewal date, which becomes the new term's start
* @param currentSeniorCitizenRate
* the senior-citizen premium the bank publishes on the renewal date for this FD type and tenure, or null to
* keep the rate the account already holds. Only applied to an account already flagged senior-citizen; the
* renewal never turns that flag on or off.
* @param baseChartFields
* identity of the CBS base rate version being applied, or null to keep the existing chart's
* @param baseChartSlabs
* the slabs of the active CBS base interest-rate version, each paired with its senior-citizen premium -- the
* rate card staff maintain and see on the Base Interest Rates screen. When empty or null the account falls
* back to the product's own chart, so a product with no base rate version still renews on something.
*/
public void renewInPlace(final BigDecimal renewedAmount, final LocalDate renewedOn, final BigDecimal currentSeniorCitizenRate,
final InterestRateChartFields baseChartFields, final Collection<DepositAccountInterestRateChart.SlabSnapshot> baseChartSlabs) {
final SavingsProduct product = this.product;
// Confines interest posting to the new term. Set before recomputing maturity, since maturity is
// measured from the current term's start.
setStartInterestCalculationDate(renewedOn);
this.accountTermAndPreClosure.updateDepositAmount(renewedAmount);
this.accountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Pin the chain number now that this account has renewed at least once, so a non-zero cycle number
// is never paired with a blank FD number. fdAccountNumber() resolves to the chain's original number
// if there is one and to this account's own otherwise, which is the same value every reader derives.
this.accountTermAndPreClosure.setFdAccountNumber(fdAccountNumber());
// Re-price off the CURRENT product, exactly as opening a fresh deposit today would.
this.nominalAnnualInterestRate = product.nominalAnnualInterestRate();
this.interestCompoundingPeriodType = product.interestCompoundingPeriodType().getValue();
this.interestPostingPeriodType = product.interestPostingPeriodType().getValue();
this.interestCalculationType = product.interestCalculationType().getValue();
this.interestCalculationDaysInYearType = product.interestCalculationDaysInYearType().getValue();
this.withHoldTax = product.withHoldTax();
// Lock-in is re-taken from the product and re-measured from this renewal, exactly as the legacy
// close-and-reopen reInvest() does for the account it creates. Left alone, a renewed term would
// carry the lock-in window computed at the original activation -- long expired -- so a product with
// a statutory lock-in (a Tax Saver, say) would hold the customer for the first term only.
final SavingsPeriodFrequencyType renewedLockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
this.lockinPeriodFrequency = product.lockinPeriodFrequency();
this.lockinPeriodFrequencyType = renewedLockinPeriodFrequencyType == null ? null
: renewedLockinPeriodFrequencyType.getValue();
this.lockedInUntilDate = this.lockinPeriodFrequencyType == null || this.lockinPeriodFrequency == null ? null
: calculateDateAccountIsLockedUntil(renewedOn);
// The new term is priced off the CBS base interest-rate master -- the rate card staff maintain on
// the Base Interest Rates screen, per FD type, with its own active version. That master is what
// the bank publishes and what the account-opening wizard already shows, so pricing a renewal off
// anything else would put the deposit on rates nobody chose.
if (this.chart != null && baseChartSlabs != null && !baseChartSlabs.isEmpty()) {
this.chart.refreshFromSlabSnapshots(baseChartFields, baseChartSlabs);
} else if (this.chart != null) {
// No active base version for this FD type: fall back to the product's own chart so the
// renewal still gets current product rates rather than the ones frozen at opening.
// activeChart, not applicableChart -- the latter treats a chart with no end date as valid only
// up to today's business date, so a renewal dated in the future would find nothing.
final InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(renewedOn)
: null;
if (productChart != null) {
this.chart.refreshFrom(productChart);
}
}
// The senior-citizen premium is stored per account and takes priority over the chart in
// getEffectiveInterestRateAsFraction(), so leaving it alone would price every future term of a
// senior-citizen deposit at the rate captured when the account was opened. Only overwritten when
// the caller found a currently published rate -- otherwise the existing one stands, so a missing
// rate version can never silently drop the depositor's premium.
if (isSeniorCitizen() && currentSeniorCitizenRate != null && currentSeniorCitizenRate.compareTo(BigDecimal.ZERO) > 0) {
setSeniorCitizenInterestRate(currentSeniorCitizenRate);
}
// The account never left ACTIVE, but updateMaturityStatus() flipped it to MATURED just before
// this ran, so the new term has to put it back.
this.status = SavingsAccountStatusType.ACTIVE.getValue();
setClosedOnDate(null);
}
public FixedDepositAccount reInvest(BigDecimal depositAmount, ExternalId externalId) {
final DepositAccountTermAndPreClosure newAccountTermAndPreClosure = this.accountTermAndPreClosure.copy(depositAmount);
final SavingsProduct product = this.product;
final InterestRateChart productChart = product.applicableChart(getClosedOnDate());
// Effective-dated pick, same as renewInPlace: an open-ended chart stays valid past today's
// business date, and the most recently effective chart wins when more than one covers the date.
// Falls back to applicableChart so a product whose charts have no usable from-date still resolves
// as it did before.
InterestRateChart productChart = product instanceof FixedDepositProduct
? ((FixedDepositProduct) product).activeChart(getClosedOnDate())
: null;
if (productChart == null) {
productChart = product.applicableChart(getClosedOnDate());
}
final DepositAccountInterestRateChart newChart = DepositAccountInterestRateChart.from(productChart);
final AccountType accountType = AccountType.fromInt(this.accountType);
final SavingsPostingInterestPeriodType postingPeriodType = SavingsPostingInterestPeriodType.fromInt(this.interestPostingPeriodType);
final SavingsCompoundingInterestPeriodType compoundingPeriodType = SavingsCompoundingInterestPeriodType
.fromInt(this.interestCompoundingPeriodType);
final SavingsInterestCalculationType interestCalculationType = SavingsInterestCalculationType.fromInt(this.interestCalculationType);
final SavingsInterestCalculationDaysInYearType daysInYearType = SavingsInterestCalculationDaysInYearType
.fromInt(this.interestCalculationDaysInYearType);
// Re-price every interest-mechanics setting off the CURRENT product, not this (old) account's
// frozen-at-opening snapshot -- same reasoning as interestRate/newChart below. Without this, a
// renewed FD kept running on whatever compounding/posting/calculation-type/lock-in/tax setting
// was in force when the ORIGINAL account was opened, silently ignoring every product edit made
// since (e.g. Daily->Quarterly compounding, or a lock-in added later).
final SavingsPostingInterestPeriodType postingPeriodType = product.interestPostingPeriodType();
final SavingsCompoundingInterestPeriodType compoundingPeriodType = product.interestCompoundingPeriodType();
final SavingsInterestCalculationType interestCalculationType = product.interestCalculationType();
final SavingsInterestCalculationDaysInYearType daysInYearType = product.interestCalculationDaysInYearType();
final BigDecimal minRequiredOpeningBalance = null;
final BigDecimal interestRate = BigDecimal.ZERO;
// Falls back to the product's own flat rate -- same fallback DepositAccountAssembler uses
// for a brand-new account -- so a product with no chart slab covering this tenure/amount
// doesn't silently reinvest the customer at 0%. getEffectiveInterestRateAsFraction() still
// re-prices off newChart (the product's CURRENT chart, reflecting any edits made since the
// original account was opened) whenever a matching slab exists.
final BigDecimal interestRate = product.nominalAnnualInterestRate();
final Set<SavingsAccountCharge> savingsAccountCharges = null;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = SavingsPeriodFrequencyType.fromInt(this.lockinPeriodFrequencyType);
final Integer lockinPeriodFrequency = this.lockinPeriodFrequency;
final SavingsPeriodFrequencyType lockinPeriodFrequencyType = product.lockinPeriodFrequencyType();
final Integer lockinPeriodFrequency = product.lockinPeriodFrequency();
final boolean withdrawalFeeApplicableForTransfer = false;
final String accountNumber = null;
final boolean withHoldTax = this.withHoldTax;
final boolean withHoldTax = product.withHoldTax();
final FixedDepositAccount reInvestedAccount = FixedDepositAccount.createNewApplicationForSubmittal(client, group, product,
savingsOfficer, accountNumber, externalId, accountType, getClosedOnDate(), closedBy, interestRate, compoundingPeriodType,
postingPeriodType, interestCalculationType, daysInYearType, minRequiredOpeningBalance, lockinPeriodFrequency,
@@ -842,6 +1037,30 @@ public class FixedDepositAccount extends SavingsAccount {
newAccountTermAndPreClosure.updateAccountReference(reInvestedAccount);
newChart.updateDepositAccountReference(reInvestedAccount);
// Carry the senior-citizen premium forward -- without this, a senior-citizen depositor's
// reinvested FD silently drops back to the standard rate on the new term.
reInvestedAccount.setSeniorCitizen(this.isSeniorCitizen());
reInvestedAccount.setSeniorCitizenInterestRate(this.getSeniorCitizenInterestRate());
// Track which account this was reinvested from, so staff/UI can trace the two accounts to
// each other instead of the link disappearing the moment the old one closes.
newAccountTermAndPreClosure.setReinvestedFromSavingsId(this.getId());
// Carry the ORIGINAL account's number forward across every renewal, so the customer keeps
// seeing one recognisable FD number even though each term is its own account/ledger row.
final String rootFdAccountNumber = this.accountTermAndPreClosure.getFdAccountNumber() != null
? this.accountTermAndPreClosure.getFdAccountNumber()
: this.getAccountNumber();
newAccountTermAndPreClosure.setFdAccountNumber(rootFdAccountNumber);
// Number this term within the chain, so the UI can label it "Renewal 2 of FD ...048".
newAccountTermAndPreClosure.setRenewalCycleNo(this.accountTermAndPreClosure.getRenewalCycleNo() + 1);
// Without this the renewed deposit has no maturity instruction, so it would mature once more and
// then sit idle rather than rolling over again as the customer asked. Done here rather than in
// the shared copy(), which Recurring Deposit renewal also uses.
newAccountTermAndPreClosure.carryForwardStandingInstructionsFrom(this.accountTermAndPreClosure);
return reInvestedAccount;
}
@@ -883,7 +1102,9 @@ public class FixedDepositAccount extends SavingsAccount {
@Override
public void loadLazyCollections() {
super.loadLazyCollections();
this.chart.getId();
if (this.chart != null) {
this.chart.getId();
}
}
public BigDecimal getDepositAmount() {
@@ -899,6 +1120,52 @@ public class FixedDepositAccount extends SavingsAccount {
return SavingsAccountStatusType.MATURED.getValue().equals(this.status);
}
/**
* Exposes the parent class's protected lock-in check to callers outside this class hierarchy. Needed by the
* Payment action's domain-service method, which enforces this itself for its reinvest branches rather than going
* through {@link #prematureClosure}, since those branches keep the account open on the same term instead of
* closing it.
*/
public boolean isLockedOnDate(final LocalDate transactionDate) {
return isAccountLocked(transactionDate);
}
/**
* The date guards {@link #close} and {@link #prematureClosure} apply, minus the maturity-date bound, for a Payment
* that renews the deposit instead of closing it and so never reaches either of those methods.
* <p>
* Dropping the maturity bound is the point: Payment is allowed on an already-matured deposit. The other three
* matter more here than they do on a closure, because a renewal turns this date into the new term's interest
* pivot -- backdate it behind interest that has already been posted and the engine regenerates those posting
* periods, reverses the settled postings and re-posts them, which is exactly what the pivot exists to prevent.
*/
public void validateRenewalDate(final LocalDate renewedOn) {
final List<ApiParameterError> dataValidationErrors = new ArrayList<>();
final DataValidatorBuilder baseDataValidator = new DataValidatorBuilder(dataValidationErrors)
.resource(FIXED_DEPOSIT_ACCOUNT_RESOURCE_NAME + DepositsApiConstants.preMatureCloseAction);
if (DateUtils.isAfterBusinessDate(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("cannot.be.a.future.date");
}
if (DateUtils.isBefore(renewedOn, getActivationDate())) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.activation.date");
}
final List<SavingsAccountTransaction> savingsAccountTransactions = retrieveListOfTransactions();
if (!savingsAccountTransactions.isEmpty()) {
final SavingsAccountTransaction lastTransaction = savingsAccountTransactions.get(savingsAccountTransactions.size() - 1);
if (lastTransaction.isAfter(renewedOn)) {
baseDataValidator.reset().parameter(SavingsApiConstants.closedOnDateParamName).value(renewedOn)
.failWithCode("must.be.after.last.transaction.date");
}
}
if (!dataValidationErrors.isEmpty()) {
throw new PlatformApiDataValidationException(dataValidationErrors);
}
}
public void setClosedOnDate(final LocalDate closedOnDate) {
this.closedOnDate = closedOnDate;
}
@@ -0,0 +1,99 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One slab of the full tenure-by-rate chart a term of an FD was priced on, archived against that term's
* {@link FixedDepositRenewalHistory} row.
* <p>
* {@code renewInPlace} reuses the same account row across renewals, overwriting the account's own interest-rate chart in
* place at every renewal. {@code FixedDepositRenewalHistory} preserves the single effective rate that resulted, but not
* the chart it came from -- this table is what lets a later query answer "what did the whole rate card look like during
* term 2", not just "what rate applied".
*/
@Entity
@Table(name = "m_fd_renewal_chart_slab_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalChartSlabHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "fd_renewal_history_id", nullable = false)
private Long fdRenewalHistoryId;
@Column(name = "chart_name", length = 100)
private String chartName;
@Column(name = "description", length = 200)
private String description;
@Column(name = "period_type_enum")
private Integer periodTypeEnum;
@Column(name = "from_period")
private Integer fromPeriod;
@Column(name = "to_period")
private Integer toPeriod;
@Column(name = "amount_range_from", scale = 6, precision = 19)
private BigDecimal amountRangeFrom;
@Column(name = "amount_range_to", scale = 6, precision = 19)
private BigDecimal amountRangeTo;
@Column(name = "annual_interest_rate", scale = 6, precision = 19)
private BigDecimal annualInterestRate;
@Column(name = "currency_code", length = 3)
private String currencyCode;
/**
* The senior-citizen premium published for this tenure band at the time this term was priced, carried through from the
* CBS Base Interest Rates screen rather than looked up live against whatever is published now. Null when the term was
* priced off the product's own chart (which has no senior-citizen concept) or predates this column.
*/
@Column(name = "senior_citizen_rate", scale = 6, precision = 19)
private BigDecimal seniorCitizenRate;
public static FixedDepositRenewalChartSlabHistory create(final Long fdRenewalHistoryId, final String chartName,
final String description, final Integer periodTypeEnum, final Integer fromPeriod, final Integer toPeriod,
final BigDecimal amountRangeFrom, final BigDecimal amountRangeTo, final BigDecimal annualInterestRate,
final String currencyCode, final BigDecimal seniorCitizenRate) {
final FixedDepositRenewalChartSlabHistory slab = new FixedDepositRenewalChartSlabHistory();
slab.fdRenewalHistoryId = fdRenewalHistoryId;
slab.chartName = chartName;
slab.description = description;
slab.periodTypeEnum = periodTypeEnum;
slab.fromPeriod = fromPeriod;
slab.toPeriod = toPeriod;
slab.amountRangeFrom = amountRangeFrom;
slab.amountRangeTo = amountRangeTo;
slab.annualInterestRate = annualInterestRate;
slab.currencyCode = currencyCode;
slab.seniorCitizenRate = seniorCitizenRate;
return slab;
}
}
@@ -0,0 +1,21 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalChartSlabHistoryRepository extends JpaRepository<FixedDepositRenewalChartSlabHistory, Long> {
/** The full chart a term was priced on, unordered -- callers typically sort by fromPeriod for display. */
List<FixedDepositRenewalChartSlabHistory> findByFdRenewalHistoryId(Long fdRenewalHistoryId);
}
@@ -0,0 +1,158 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.LocalDate;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One row per term of one logical Fixed Deposit, recording the renewal event that started it.
* <p>
* Each term runs as its own deposit instance (its own {@code m_savings_account} row) so that term keeps an immutable
* ledger, interest postings and GL entries, while {@link #fdAccountNumber} stays the same across the whole chain and is
* what the customer sees. What that structure alone cannot tell you is the intent and outcome of each rollover, which is
* what this table captures: the previous term's principal and interest earned, what it matured at, how much was carried
* forward, the rate applied to the new term, whether the renewal ran automatically at maturity or was performed by a
* user, and by whom.
*/
@Entity
@Table(name = "m_fd_renewal_history")
@Getter
@NoArgsConstructor
public class FixedDepositRenewalHistory {
/** Renewal ran automatically when the maturity job processed the account's maturity instruction. */
public static final String RENEWAL_TYPE_AUTO = "AUTO";
/** A user closed the account with a re-invest instruction. */
public static final String RENEWAL_TYPE_MANUAL = "MANUAL";
public static final String STATUS_ACTIVE = "ACTIVE";
public static final String STATUS_MATURED = "MATURED";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** The stable customer-facing FD number, shared by every term in the chain. */
@Column(name = "fd_account_number", length = 20, nullable = false)
private String fdAccountNumber;
/** The account the customer originally opened, i.e. the anchor of the chain. */
@Column(name = "original_savings_account_id", nullable = false)
private Long originalSavingsAccountId;
/** The deposit instance this term runs on. */
@Column(name = "savings_account_id", nullable = false)
private Long savingsAccountId;
/** The term this one was renewed from; null on the chain's first row. */
@Column(name = "previous_savings_account_id")
private Long previousSavingsAccountId;
@Column(name = "previous_renewal_id")
private Long previousRenewalId;
/** 0 for the originally opened term, 1 for the first renewal, and so on. */
@Column(name = "renewal_no", nullable = false)
private Integer renewalNo;
@Column(name = "previous_principal", scale = 6, precision = 19)
private BigDecimal previousPrincipal;
@Column(name = "interest_earned", scale = 6, precision = 19)
private BigDecimal interestEarned;
@Column(name = "maturity_amount", scale = 6, precision = 19)
private BigDecimal maturityAmount;
/** What actually carried into this term: the full maturity amount, or principal only. */
@Column(name = "renewed_amount", scale = 6, precision = 19)
private BigDecimal renewedAmount;
@Column(name = "interest_rate", scale = 6, precision = 19)
private BigDecimal interestRate;
@Column(name = "start_date")
private LocalDate startDate;
@Column(name = "maturity_date")
private LocalDate maturityDate;
@Column(name = "tenure")
private Integer tenure;
@Column(name = "tenure_frequency_enum")
private Integer tenureFrequencyEnum;
@Column(name = "renewal_type", length = 10)
private String renewalType;
@Column(name = "renewal_status", length = 20)
private String renewalStatus;
@Column(name = "renewed_on")
private LocalDate renewedOn;
@Column(name = "created_by_id")
private Long createdById;
public static FixedDepositRenewalHistory create(final String fdAccountNumber, final Long originalSavingsAccountId,
final Long savingsAccountId, final Long previousSavingsAccountId, final Long previousRenewalId, final Integer renewalNo,
final BigDecimal previousPrincipal, final BigDecimal interestEarned, final BigDecimal maturityAmount,
final BigDecimal renewedAmount, final BigDecimal interestRate, final LocalDate startDate, final LocalDate maturityDate,
final Integer tenure, final Integer tenureFrequencyEnum, final String renewalType, final LocalDate renewedOn,
final Long createdById) {
final FixedDepositRenewalHistory history = new FixedDepositRenewalHistory();
history.fdAccountNumber = fdAccountNumber;
history.originalSavingsAccountId = originalSavingsAccountId;
history.savingsAccountId = savingsAccountId;
history.previousSavingsAccountId = previousSavingsAccountId;
history.previousRenewalId = previousRenewalId;
history.renewalNo = renewalNo;
history.previousPrincipal = previousPrincipal;
history.interestEarned = interestEarned;
history.maturityAmount = maturityAmount;
history.renewedAmount = renewedAmount;
history.interestRate = interestRate;
history.startDate = startDate;
history.maturityDate = maturityDate;
history.tenure = tenure;
history.tenureFrequencyEnum = tenureFrequencyEnum;
history.renewalType = renewalType;
history.renewalStatus = STATUS_ACTIVE;
history.renewedOn = renewedOn;
history.createdById = createdById;
return history;
}
/** Called on the previous term's row once its successor takes over. */
public void markMatured() {
this.renewalStatus = STATUS_MATURED;
}
/**
* Fills in what this term actually earned and matured at, which is only known once it closes. Written on the term's
* own row so each term reports its own outcome rather than that of its predecessor.
*/
public void recordOutcome(final BigDecimal interestEarned, final BigDecimal maturityAmount) {
this.interestEarned = interestEarned;
this.maturityAmount = maturityAmount;
}
}
@@ -0,0 +1,30 @@
/**
* 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
*/
package org.apache.fineract.portfolio.savings.domain;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
public interface FixedDepositRenewalHistoryRepository extends JpaRepository<FixedDepositRenewalHistory, Long> {
/** One FD's whole history, oldest term first. */
List<FixedDepositRenewalHistory> findByFdAccountNumberOrderByRenewalNoAsc(String fdAccountNumber);
/**
* An account's terms, newest first.
* <p>
* Deliberately a list, not a single row: an FD that renews on the same account has one row per term all pointing at
* that one account, so callers wanting the term currently running take the first element. Empty for an account that
* pre-dates this table or was never renewed.
*/
List<FixedDepositRenewalHistory> findBySavingsAccountIdOrderByRenewalNoDesc(Long savingsAccountId);
}
@@ -552,7 +552,10 @@ public class RecurringDepositAccount extends SavingsAccount {
@Override
public Money activateWithBalance() {
return Money.of(this.currency, this.minRequiredOpeningBalance);
// Activation never deposits money -- funding happens strictly through a separate deposit
// transaction (posted by a clerk) after the account is already active, regardless of any
// minimum required opening balance the product has configured.
return Money.zero(this.currency);
}
protected void processAccountUponActivation(final DateTimeFormatter fmt, final boolean postReversals,
@@ -19,7 +19,6 @@
package org.apache.fineract.portfolio.savings.domain;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.SAVINGS_ACCOUNT_RESOURCE_NAME;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountFacilityConfigParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountNoParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.accountStatusParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.guardianClientIdParamName;
@@ -455,18 +454,10 @@ public class SavingsAccountAssembler {
account.setLinkedAccountType(this.fromApiJsonHelper.extractStringNamed(linkedAccountTypeParamName, element));
account.setLinkedAccountNo(this.fromApiJsonHelper.extractStringNamed(linkedAccountNoParamName, element));
// Account Facility Config
if (element.getAsJsonObject().has(accountFacilityConfigParamName)) {
final JsonElement configEl = element.getAsJsonObject().get(accountFacilityConfigParamName);
if (configEl != null && !configEl.isJsonNull() && configEl.isJsonObject()) {
SavingsAccountFacilityConfig facilityConfig = account.getFacilityConfig();
if (facilityConfig == null) {
facilityConfig = new SavingsAccountFacilityConfig(account);
account.setFacilityConfig(facilityConfig);
}
facilityConfig.updateFrom(configEl.getAsJsonObject());
}
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(new SavingsAccountFacilityConfig(account));
// Joint holders
if (element.getAsJsonObject().has(jointHoldersParamName)) {
@@ -0,0 +1,46 @@
/**
* 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.handler;
import org.apache.fineract.commands.annotation.CommandType;
import org.apache.fineract.commands.handler.NewCommandSourceHandler;
import org.apache.fineract.infrastructure.core.api.JsonCommand;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.portfolio.savings.service.DepositAccountWritePlatformService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@CommandType(entity = "FIXEDDEPOSITACCOUNT", action = "PAYMENT")
public class PaymentFixedDepositAccountCommandHandler implements NewCommandSourceHandler {
private final DepositAccountWritePlatformService depositAccountWritePlatformService;
@Autowired
public PaymentFixedDepositAccountCommandHandler(final DepositAccountWritePlatformService depositAccountWritePlatformService) {
this.depositAccountWritePlatformService = depositAccountWritePlatformService;
}
@Transactional
@Override
public CommandProcessingResult processCommand(final JsonCommand command) {
return this.depositAccountWritePlatformService.processFdPayment(command.entityId(), command);
}
}
@@ -133,18 +133,10 @@ public class CurrentAccountTransactionWritePlatformServiceImpl implements Curren
"Insufficient available balance. Available: " + available + ", Requested: " + amount, accountId);
}
// Minimum required balance validation
// Minimum required balance is not enforced -- a withdrawal must never be blocked for
// taking the account below its configured minimum, only for exceeding the available
// balance (checked above).
final BigDecimal newBalance = currentBalance.subtract(amount);
if (account.isEnforceMinRequiredBalance() && account.getMinRequiredBalance() != null
&& account.getMinRequiredBalance().compareTo(BigDecimal.ZERO) > 0) {
if (newBalance.compareTo(account.getMinRequiredBalance()) < 0) {
throw new GeneralPlatformDomainRuleException(
"error.msg.current.account.min.required.balance.breached",
"Withdrawal of " + amount + " would breach the minimum required balance of "
+ account.getMinRequiredBalance() + ". Balance after withdrawal would be " + newBalance,
accountId);
}
}
final String refNo = command.stringValueOfParameterNamed("accountNumber");
final CurrentAccountTransaction txn = CurrentAccountTransaction.withdrawal(account, office, amount, txnDate, refNo);
@@ -227,12 +227,10 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
clientNomineeWritePlatformService.saveNomineeIfPresent(clientId, accountNo, "CB", json.get("nominee"));
}
// Facility config — UI sends "accountFacilityConfig", internal uses "facilityConfig"
JsonObject fcJson = json.getAsJsonObject("accountFacilityConfig");
if (fcJson == null) fcJson = json.getAsJsonObject("facilityConfig");
if (fcJson != null) {
account.setFacilityConfig(assembleFacilityConfig(account, fcJson));
}
// Every digital facility starts disabled at account opening, regardless of anything the
// request sends or the product defaults to -- they're switched on deliberately, one at a
// time, later, through the account's own Edit Nominee & Features screen.
account.setFacilityConfig(CurrentAccountFacilityConfig.createFor(account));
// Joint holders
if (json.has("jointHolders") && json.get("jointHolders").isJsonArray()) {
@@ -294,61 +292,16 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
final JsonObject json = command.parsedJson().getAsJsonObject();
LocalDate activatedOnDate = parseDate(getString(json, "activatedOnDate"), getString(json, "dateFormat"), getString(json, "locale"));
if (activatedOnDate == null) activatedOnDate = LocalDate.now(DateUtils.getDateTimeZoneOfTenant());
// Validate minimum opening balance before activation
java.math.BigDecimal openingBalanceValue = java.math.BigDecimal.ZERO;
if (json.has("openingBalance") && !json.get("openingBalance").isJsonNull()) {
try { openingBalanceValue = json.get("openingBalance").getAsBigDecimal(); } catch (Exception ignored) {
// intentionally ignored
}
}
final java.math.BigDecimal minRequired = account.getMinRequiredOpeningBalance();
if (minRequired != null && minRequired.compareTo(java.math.BigDecimal.ZERO) > 0
&& openingBalanceValue.compareTo(minRequired) < 0) {
throw new org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException(
java.util.List.of(org.apache.fineract.infrastructure.core.data.ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceValue + ") is less than minimum required opening balance (" + minRequired + ")",
"validation.msg.currentaccount.openingBalance.less.than.min.required",
openingBalanceValue, minRequired)));
}
account.activate(activatedOnDate, user);
applyFacilityChargesOnActivation(account);
java.math.BigDecimal currentBalance = java.math.BigDecimal.ZERO;
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction> depositTxns = new java.util.ArrayList<>();
final java.util.List<ChargeGlEntry> chargeEntries = new java.util.ArrayList<>();
// Opening balance deposit transaction
if (openingBalanceValue.compareTo(java.math.BigDecimal.ZERO) > 0) {
try {
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction deposit =
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction
.deposit(account, account.getOffice(), openingBalanceValue, activatedOnDate, "OPENING-BALANCE");
deposit.setRunningBalanceDerived(openingBalanceValue);
account.setTotalDepositsDerived(openingBalanceValue);
currentAccountTransactionRepository.saveAndFlush(deposit);
currentBalance = openingBalanceValue;
depositTxns.add(deposit);
log.info("Opening balance deposit {} created for account id={}", openingBalanceValue, accountId);
} catch (Exception e) {
log.warn("Could not process openingBalance for account id={}: {}", accountId, e.getMessage());
}
}
// Auto-deduct activation charges (chargeTimeEnum=3) from opening balance
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> activationCharges =
account.getCharges().stream()
.filter(c -> c.getChargeTimeEnum() != null && c.getChargeTimeEnum() == 3 && !c.isPaid())
.toList();
currentBalance = collectChargesAndPost(account, activationCharges, currentBalance, activatedOnDate, chargeEntries);
account.setAccountBalanceDerived(currentBalance);
// Activation posts no transaction of its own -- no opening-balance deposit, no
// digital-facility charges applied or collected, regardless of what the account's facility
// config or product defaults say. Funding and any facility charges happen strictly
// afterwards, through separate transactions a clerk processes once the account is already
// active.
currentAccountRepository.saveAndFlush(account);
// Post GL journal entries — deposits in batch, each charge individually
postGlEntries(account, depositTxns, chargeEntries);
return new CommandProcessingResultBuilder().withEntityId(accountId).build();
}
@@ -637,78 +590,6 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyFacilityChargesOnActivation(final CurrentAccount account) {
// Use account-level facility config — reflects what was selected at account opening time
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig config =
account.getFacilityConfig();
log.info("[FacilityCharge] Activating account id={}, facilityConfig={}",
account.getId(), config != null ? "FOUND" : "NULL");
if (config == null) {
// Fallback: use product-level facility config
log.info("[FacilityCharge] No account-level config, falling back to product id={}", account.getProductId());
config = currentProductRepository.findById(account.getProductId())
.map(p -> {
final var pc = p.getFacilityConfig();
if (pc == null) {
log.info("[FacilityCharge] Product has no facility config either");
return null;
}
// Map product config to account config fields inline
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig ac =
new org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig();
ac.setAtmFacility(Boolean.TRUE.equals(pc.getAtmFacility()));
ac.setAtmWithdrawalFacility(Boolean.TRUE.equals(pc.getAtmWithdrawalFacility()));
ac.setUpiFacility(Boolean.TRUE.equals(pc.getUpiFacility()));
ac.setPosTransactionsFacility(Boolean.TRUE.equals(pc.getPosTransactionsFacility()));
ac.setChequeBook(Boolean.TRUE.equals(pc.getChequeBook()));
ac.setImpsFacility(Boolean.TRUE.equals(pc.getImpsFacility()));
ac.setSmsAlerts(Boolean.TRUE.equals(pc.getSmsAlerts()));
ac.setBbpsFacility(Boolean.TRUE.equals(pc.getBbpsFacility()));
ac.setInsuranceCover(Boolean.TRUE.equals(pc.getInsuranceCover()));
ac.setBepgFacility(Boolean.TRUE.equals(pc.getBepgFacility()));
ac.setNeftFacility(Boolean.TRUE.equals(pc.getNeftFacility()));
ac.setRtgsFacility(Boolean.TRUE.equals(pc.getRtgsFacility()));
ac.setInterBankTotalCapFacility(Boolean.TRUE.equals(pc.getInterBankTotalCapFacility()));
ac.setWithdrawalFacility(Boolean.TRUE.equals(pc.getWithdrawalFacility()));
ac.setDepositFacilityKyc(Boolean.TRUE.equals(pc.getDepositFacilityKyc()));
ac.setVirtualDebitCard(Boolean.TRUE.equals(pc.getVirtualDebitCard()));
return ac;
}).orElse(null);
}
if (config == null) {
log.warn("[FacilityCharge] No facility config found for account id={} — no charges applied", account.getId());
return;
}
final org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountFacilityConfig fc = config;
log.info("[FacilityCharge] Config found — ATM={} UPI={} NEFT={} RTGS={} CHEQUE={} SMS={} IMPs={} POS={} BBPS={} INS={} BEPG={} IBCAP={} WD={} KYC={} VCARD={} ATM_WD={}",
fc.isAtmFacility(), fc.isUpiFacility(), fc.isNeftFacility(), fc.isRtgsFacility(),
fc.isChequeBook(), fc.isSmsAlerts(), fc.isImpsFacility(), fc.isPosTransactionsFacility(),
fc.isBbpsFacility(), fc.isInsuranceCover(), fc.isBepgFacility(), fc.isInterBankTotalCapFacility(),
fc.isWithdrawalFacility(), fc.isDepositFacilityKyc(), fc.isVirtualDebitCard(), fc.isAtmWithdrawalFacility());
applyMapped(account, fc.isAtmFacility(), "ATM_FACILITY");
applyMapped(account, fc.isAtmWithdrawalFacility(), "ATM_WITHDRAWAL");
applyMapped(account, fc.isUpiFacility(), "UPI_FACILITY");
applyMapped(account, fc.isPosTransactionsFacility(), "POS_TRANSACTIONS");
// Cheque book: no longer charged at activation — the per-leaf issuance charge is now deducted
// when a cheque book is actually issued (ChequeBookIssuanceChargeService), not just for having
// the facility enabled.
applyMapped(account, fc.isImpsFacility(), "IMPS_FACILITY");
applyMapped(account, fc.isSmsAlerts(), "SMS_ALERTS");
applyMapped(account, fc.isBbpsFacility(), "BBPS_FACILITY");
applyMapped(account, fc.isInsuranceCover(), "INSURANCE_COVER");
applyMapped(account, fc.isBepgFacility(), "BEPG_FACILITY");
applyMapped(account, fc.isNeftFacility(), "NEFT_FACILITY");
applyMapped(account, fc.isRtgsFacility(), "RTGS_FACILITY");
applyMapped(account, fc.isInterBankTotalCapFacility(),"INTER_BANK_TOTAL_CAP");
applyMapped(account, fc.isWithdrawalFacility(), "WITHDRAWAL_FACILITY");
applyMapped(account, fc.isDepositFacilityKyc(), "DEPOSIT_FACILITY_KYC");
applyMapped(account, fc.isVirtualDebitCard(), "VIRTUAL_DEBIT_CARD");
}
// Wrapper to carry a charge transaction together with its charge entity for GL posting
private record ChargeGlEntry(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountTransaction txn,
@@ -717,9 +598,8 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
/**
* Marks each of the given (unpaid) charges as paid, deducts its amount from {@code runningBalance}, creates a
* {@code CurrentAccountTransaction.chargeDeduction(...)} row for it, and records a {@link ChargeGlEntry} for GL
* posting. Shared by {@link #activate(Long, JsonCommand)} (activation-time charges) and
* {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) so the
* charge-collection logic isn't duplicated. Returns the resulting balance.
* posting. Used by {@link #applyAndCollectFacilityCharges(Long, java.util.Set)} (edit-approval-time charges) --
* activation itself no longer collects any charges. Returns the resulting balance.
*/
private java.math.BigDecimal collectChargesAndPost(final CurrentAccount account,
final java.util.List<org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge> charges,
@@ -875,24 +755,4 @@ public class CurrentAccountWritePlatformServiceImpl implements CurrentAccountWri
}
}
private void applyMapped(final CurrentAccount account, final boolean enabled, final String facilityType) {
if (!enabled) {
log.debug("[FacilityCharge] {} disabled — skip", facilityType);
return;
}
final var mappingOpt = facilityChargeMappingRepository.findByFacilityType(facilityType);
if (mappingOpt.isEmpty()) {
log.warn("[FacilityCharge] {} enabled but NO mapping found in m_facility_charge_mapping", facilityType);
return;
}
final org.apache.fineract.portfolio.charge.domain.Charge charge = mappingOpt.get().getCharge();
if (charge == null) {
log.warn("[FacilityCharge] {} mapping exists but charge_id is NULL", facilityType);
return;
}
log.info("[FacilityCharge] Applying charge '{}' (id={}) for facility {}", charge.getName(), charge.getId(), facilityType);
account.getCharges().add(
org.apache.fineract.portfolio.currentaccount.domain.CurrentAccountCharge
.createFromFacility(account, charge));
}
}
@@ -189,6 +189,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
.append("ircd.id as ircdId, ircd.description as ircdDescription, ircd.period_type_enum ircdPeriodTypeId, ")
.append("ircd.from_period as ircdFromPeriod, ircd.to_period as ircdToPeriod, ircd.amount_range_from as ircdAmountRangeFrom, ")
.append("ircd.amount_range_to as ircdAmountRangeTo, ircd.annual_interest_rate as ircdAnnualInterestRate, ")
.append("ircd.senior_citizen_rate as ircdSeniorCitizenRate, ")
.append("curr.code as currencyCode, curr.name as currencyName, curr.internationalized_name_code as currencyNameCode, ")
.append("curr.display_symbol as currencyDisplaySymbol, curr.decimal_places as currencyDigits, curr.currency_multiplesof as inMultiplesOf, ")
.append("sa.id as accountId, sa.account_no as accountNumber, ").append("iri.id as iriId, ")
@@ -346,6 +347,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
final BigDecimal amountRangeFrom = rs.getBigDecimal("ircdAmountRangeFrom");
final BigDecimal amountRangeTo = rs.getBigDecimal("ircdAmountRangeTo");
final BigDecimal annualInterestRate = rs.getBigDecimal("ircdAnnualInterestRate");
final BigDecimal seniorCitizenRate = rs.getBigDecimal("ircdSeniorCitizenRate");
// currency Slabs
final String currencyCode = rs.getString("currencyCode");
@@ -359,7 +361,7 @@ public class DepositAccountInterestRateChartReadPlatformServiceImpl implements D
currencyNameCode);
return DepositAccountInterestRateChartSlabData.instance(id, description, periodType, fromPeriod, toPeriod, amountRangeFrom,
amountRangeTo, annualInterestRate, currency);
amountRangeTo, annualInterestRate, currency, seniorCitizenRate);
}
}
@@ -46,6 +46,12 @@ public interface DepositAccountReadPlatformService {
DepositAccountData retrieveOneWithClosureTemplate(DepositAccountType depositAccountType, Long accountId);
/**
* As {@link #retrieveOneWithClosureTemplate}, but for the FD Payment action -- so its option list also carries
* PARTIAL_REINVEST, which the closure commands deliberately exclude because only Payment implements it.
*/
DepositAccountData retrieveOneWithPaymentTemplate(DepositAccountType depositAccountType, Long accountId);
DepositAccountData retrieveOneWithChartSlabs(DepositAccountType depositAccountType, Long productId);
Collection<SavingsAccountTransactionData> retrieveAllTransactions(DepositAccountType depositAccountType, Long accountId);
@@ -321,6 +321,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
((org.apache.fineract.portfolio.savings.data.RecurringDepositAccountData) account).setJointHolders(jointHolders);
}
}
if (account instanceof org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) {
applyReinvestmentLinks((org.apache.fineract.portfolio.savings.data.FixedDepositAccountData) account, accountId);
}
}
return account;
@@ -329,6 +332,158 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}
}
/**
* Populates the "reinvested from" / "reinvested into" links on a Fixed Deposit account, so the
* account-view page can show either direction of a "Re-Invest Maturity Amount"/"Re-Invest
* Principal Only" closure. There is only one column (reinvested_from_savings_id, set on the NEW
* account) -- "reinvested into" is found on the OLD account by reverse-looking up whichever row
* points at it.
*/
private void applyReinvestmentLinks(final org.apache.fineract.portfolio.savings.data.FixedDepositAccountData result,
final Long accountId) {
final String fromSql = "select src.id as id, src.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t "
+ "join m_savings_account src on src.id = t.reinvested_from_savings_id " + "where t.savings_account_id = ?";
final String intoSql = "select dest.id as id, dest.account_no as accountNo "
+ "from m_deposit_account_term_and_preclosure t " + "join m_savings_account dest on dest.id = t.savings_account_id "
+ "where t.reinvested_from_savings_id = ?";
Long fromId = null;
String fromNo = null;
final java.util.List<java.util.Map<String, Object>> fromRows = this.jdbcTemplate.queryForList(fromSql, accountId);
if (!fromRows.isEmpty()) {
fromId = ((Number) fromRows.get(0).get("id")).longValue();
fromNo = (String) fromRows.get(0).get("accountNo");
}
Long intoId = null;
String intoNo = null;
final java.util.List<java.util.Map<String, Object>> intoRows = this.jdbcTemplate.queryForList(intoSql, accountId);
if (!intoRows.isEmpty()) {
intoId = ((Number) intoRows.get(0).get("id")).longValue();
intoNo = (String) intoRows.get(0).get("accountNo");
}
if (fromId != null || intoId != null) {
result.setReinvestmentLinks(fromId, fromNo, intoId, intoNo);
}
// The number to actually show as "the FD account number" -- the original account's account_no
// if this account has been through a renewal, otherwise this account's own account_no.
final String termSql = "select fd_account_number, renewal_cycle_no from m_deposit_account_term_and_preclosure "
+ "where savings_account_id = ?";
final java.util.List<java.util.Map<String, Object>> termRows = this.jdbcTemplate.queryForList(termSql, accountId);
final String fdAccountNumber = termRows.isEmpty() ? null : (String) termRows.get(0).get("fd_account_number");
final Object cycleNo = termRows.isEmpty() ? null : termRows.get(0).get("renewal_cycle_no");
final String rootFdAccountNumber = fdAccountNumber != null ? fdAccountNumber : result.getAccountNo();
result.setFdAccountNumber(rootFdAccountNumber);
result.setRenewalCycleNo(cycleNo == null ? 0 : ((Number) cycleNo).intValue());
result.setRenewalHistory(retrieveRenewalHistory(rootFdAccountNumber));
}
/**
* Every term of one logical FD, oldest first.
* <p>
* Driven by m_fd_renewal_history, which records each rollover event -- interest the closing term earned, what it
* matured at, how much carried forward, the rate applied, automatic or by a user -- rather than deriving the chain by
* joining account rows, which cannot express any of that. The account row is joined in for its number and live
* status. Returns an empty list for an FD that has never been renewed, so callers can treat "has renewal history" as
* simply a non-empty list.
*/
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistory(final String fdAccountNumber) {
// Every row states its own term's figures, so this is a straight read: interest_earned and
// maturity_amount are filled in when that term closes, and stay null on the term still running,
// which the caller renders as its projected maturity instead.
final String sql = "select h.id as historyId, h.renewal_no as renewalCycleNo, h.savings_account_id as accountId, "
+ "sa.account_no as accountNo, h.renewed_amount as depositAmount, h.interest_rate as interestRate, "
+ "h.start_date as activatedOnDate, h.maturity_date as maturityDate, h.tenure as tenure, "
+ "h.tenure_frequency_enum as tenureFrequencyEnum, "
+ "h.renewal_type as renewalType, h.renewal_status as renewalStatus, sa.status_enum as statusEnum, "
+ "h.interest_earned as interestEarned, h.maturity_amount as maturedAmount, "
+ "t.maturity_amount as projectedMaturityAmount " + "from m_fd_renewal_history h "
+ "join m_savings_account sa on sa.id = h.savings_account_id "
+ "left join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where h.fd_account_number = ? " + "order by h.renewal_no asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber);
// Any row here is a renewal event that was actually recorded, so even one is worth returning --
// discarding a lone row used to hide a genuine renewal (and its interest figure) behind the
// account-derived fallback, which then returned nothing at all for it.
if (!rows.isEmpty()) {
attachArchivedChartSlabs(rows);
return rows;
}
// Chains that renewed before m_fd_renewal_history existed have no rows to read, so fall back to
// reconstructing what the account rows alone can tell us. The per-term interest/renewal-type
// columns are simply absent for those, which the UI renders as blanks rather than wrong values.
return retrieveRenewalHistoryFromAccounts(fdAccountNumber);
}
/**
* Attaches each term's archived rate-by-tenure chart (as a "chartSlabs" list) onto its own row.
* <p>
* A term's chart is only archived when it closes via an in-place renewal (see
* {@code DepositAccountDomainServiceJpa#archiveChartSlabs}), so the currently-running term and any term from before
* that archiving existed simply get an empty list here -- callers should fall back to the account's live
* accountChart.chartSlabs for those. Fetched in one batched query keyed by history id, not one query per row, since
* this runs once per FD account view.
*/
private void attachArchivedChartSlabs(final java.util.List<java.util.Map<String, Object>> rows) {
final java.util.List<Long> historyIds = new java.util.ArrayList<>();
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
if (id != null) {
historyIds.add(((Number) id).longValue());
}
}
if (historyIds.isEmpty()) {
return;
}
final String placeholders = String.join(",", java.util.Collections.nCopies(historyIds.size(), "?"));
final String sql = "select fd_renewal_history_id as historyId, chart_name as chartName, description, "
+ "period_type_enum as periodTypeEnum, from_period as fromPeriod, to_period as toPeriod, "
+ "annual_interest_rate as annualInterestRate, senior_citizen_rate as seniorCitizenRate "
+ "from m_fd_renewal_chart_slab_history " + "where fd_renewal_history_id in (" + placeholders + ") "
+ "order by from_period asc";
final java.util.List<java.util.Map<String, Object>> slabRows = this.jdbcTemplate.queryForList(sql, historyIds.toArray());
final java.util.Map<Long, java.util.List<java.util.Map<String, Object>>> slabsByHistoryId = new java.util.HashMap<>();
for (final java.util.Map<String, Object> slabRow : slabRows) {
final Long historyId = ((Number) slabRow.remove("historyId")).longValue();
slabsByHistoryId.computeIfAbsent(historyId, key -> new java.util.ArrayList<>()).add(slabRow);
}
for (final java.util.Map<String, Object> row : rows) {
final Object id = row.get("historyId");
final Long historyId = id == null ? null : ((Number) id).longValue();
row.put("chartSlabs", slabsByHistoryId.getOrDefault(historyId, java.util.Collections.emptyList()));
}
}
private java.util.List<java.util.Map<String, Object>> retrieveRenewalHistoryFromAccounts(final String fdAccountNumber) {
// Deliberately emits the SAME column labels as retrieveRenewalHistory above, so a caller never has
// to know which path produced the rows. What the account rows genuinely cannot tell us -- each
// term's interest earned, and whether the rollover was automatic or done by staff -- is returned as
// an explicit null rather than omitted, so those render as blanks instead of the previous behaviour
// where a missing renewalStatus made every term, including the running one, look matured.
final String sql = "select null as historyId, coalesce(t.renewal_cycle_no, 0) as renewalCycleNo, "
+ "sa.id as accountId, sa.account_no as accountNo, t.deposit_amount as depositAmount, "
+ "sa.nominal_annual_interest_rate as interestRate, sa.activatedon_date as activatedOnDate, "
+ "t.maturity_date as maturityDate, t.deposit_period as tenure, "
+ "t.deposit_period_frequency_enum as tenureFrequencyEnum, null as renewalType, "
+ "case when sa.status_enum = 300 then 'ACTIVE' else 'MATURED' end as renewalStatus, "
+ "sa.status_enum as statusEnum, null as interestEarned, "
+ "case when sa.status_enum = 300 then null else t.maturity_amount end as maturedAmount, "
+ "case when sa.status_enum = 300 then t.maturity_amount else null end as projectedMaturityAmount "
+ "from m_savings_account sa "
+ "join m_deposit_account_term_and_preclosure t on t.savings_account_id = sa.id "
+ "where sa.account_no = ? or t.fd_account_number = ? " + "order by coalesce(t.renewal_cycle_no, 0) asc";
final java.util.List<java.util.Map<String, Object>> rows = this.jdbcTemplate.queryForList(sql, fdAccountNumber,
fdAccountNumber);
// A single row here means this FD simply never renewed -- one account, one term -- so there is no
// history to show. Unlike the primary path, where even one recorded row is a real renewal event.
return rows.size() > 1 ? rows : java.util.Collections.emptyList();
}
private java.util.List<java.util.Map<String, Object>> retrieveJointHolders(final Long accountId) {
final String sql = "SELECT jh.id, jh.client_id, jh.cif_number, jh.display_name, "
+ "jh.share_percentage, jh.signing_authority, jh.role, jh.is_primary, jh.is_mandatory "
@@ -349,8 +504,30 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
}, accountId);
}
/**
* Closure options for the {@code close} and {@code prematureClose} commands. PARTIAL_REINVEST is excluded: only the
* Payment command's handler implements it, and only its dialog collects the kept-amount and destination account it
* needs. Offered here it would appear in the FD and RD close dialogs, where the closure handlers have no branch for
* it and would fall through to paying the whole balance out in cash.
*/
@Override
public DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = false;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
/**
* The same template, but including PARTIAL_REINVEST -- for the Payment command, which is the one action that
* implements it.
*/
@Override
public DepositAccountData retrieveOneWithPaymentTemplate(final DepositAccountType depositAccountType, final Long accountId) {
final boolean includePartialReinvest = true;
return retrieveOneWithClosureTemplate(depositAccountType, accountId, includePartialReinvest);
}
private DepositAccountData retrieveOneWithClosureTemplate(final DepositAccountType depositAccountType, final Long accountId,
final boolean includePartialReinvest) {
try {
this.context.authenticatedUser();
@@ -367,7 +544,9 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(DepositAccountOnClosureType.values());
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.getClientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -554,7 +733,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
template = RecurringDepositAccountData.withInterestChartAndRecurringDetails((RecurringDepositAccountData) template,
accountChart, null, null);
// Re-apply CBS product fields lost during factory method object creation
@@ -626,7 +805,7 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
fieldOfficerOptions, interestCompoundingPeriodTypeOptions, interestPostingPeriodTypeOptions,
interestCalculationTypeOptions, interestCalculationDaysInYearTypeOptions, lockinPeriodFrequencyTypeOptions,
withdrawalFeeTypeOptions, transactions, charges, chargeOptions, preClosurePenalInterestOnTypeOptions,
periodFrequencyTypeOptions);
periodFrequencyTypeOptions, savingsAccountDatas, maturityInstructionOptions);
}
}
@@ -48,6 +48,7 @@ import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResultBuilder;
import org.apache.fineract.infrastructure.core.data.DataValidatorBuilder;
import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.exception.PlatformApiDataValidationException;
import org.apache.fineract.infrastructure.core.exception.PlatformServiceUnavailableException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -172,8 +173,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
if (portfolioAccountData == null) {
final PaymentDetail paymentDetail = null;
this.depositAccountDomainService.handleFDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), paymentDetail);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleFDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), paymentDetail);
// handleFDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes, e.g. an activation charge payment) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final SavingsAccount fromSavingsAccount = null;
boolean isRegularTransaction = false;
@@ -202,8 +209,17 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
if (amountForDeposit.isGreaterThanZero()) {
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
} else {
// No deposit has actually landed on the account yet -- project maturity using the
// configured deposit amount (the same approach used for the pre-activation preview and
// for reinvested FDs) instead of the account's real, still-empty transaction history,
// which would otherwise price in zero interest.
account.updateMaturityDateAndAmountBeforeAccountActivation(mc, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
}
List<DepositAccountOnHoldTransaction> depositAccountOnHoldTransactions = null;
if (account.getOnHoldFunds().compareTo(BigDecimal.ZERO) > 0) {
depositAccountOnHoldTransactions = this.depositAccountOnHoldTransactionRepository
@@ -275,8 +291,14 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final PortfolioAccountData portfolioAccountData = this.accountAssociationsReadPlatformService
.retriveSavingsLinkedAssociation(savingsId);
if (portfolioAccountData == null) {
this.depositAccountDomainService.handleRDDeposit(account, fmt, account.getActivationDate(),
amountForDeposit.getAmount(), null, isRegularTransaction);
final SavingsAccountTransaction depositTransaction = this.depositAccountDomainService.handleRDDeposit(account, fmt,
account.getActivationDate(), amountForDeposit.getAmount(), null, isRegularTransaction);
// handleRDDeposit() already posts this transaction's own journal entries internally --
// mark it as "existing" so the postJournalEntries(...) call below (which covers other
// activation-time changes) does not post it again.
if (depositTransaction != null && depositTransaction.getId() != null) {
existingTransactionIds.add(depositTransaction.getId());
}
} else {
final boolean isExceptionForBalanceCheck = false;
final SavingsAccount fromSavingsAccount = null;
@@ -368,6 +390,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
final SavingsAccountTransaction deposit = this.depositAccountDomainService.handleFDDeposit(account, fmt, transactionDate,
transactionAmount, paymentDetail);
// The account may have been activated with no funds yet (opening deposit is now a separate,
// later step) -- in that case its maturity amount was only ever projected off the configured
// deposit amount, with zero real transactions. Now that money has actually landed, recompute
// maturity date/amount off the account's real transaction history so it reflects reality.
final boolean isSavingsInterestPostingAtCurrentPeriodEnd = this.configurationDomainService
.isSavingsInterestPostingAtCurrentPeriodEnd();
final Integer financialYearBeginningMonth = this.configurationDomainService.retrieveFinancialYearBeginningMonth();
final MathContext mc = MathContext.DECIMAL64;
final boolean isPreMatureClosure = false;
account.updateMaturityDateAndAmount(mc, isPreMatureClosure, isSavingsInterestPostingAtCurrentPeriodEnd,
financialYearBeginningMonth);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
this.denominationBreakdownWriter.save(command, deposit.getId(), account.getId(), account.getAccountNumber(),
account.clientId(), account.getClient() != null ? account.getClient().getCifNumber() : null, transactionDate,
"DEPOSIT");
@@ -878,6 +913,67 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
}
@Override
public CommandProcessingResult processFdPayment(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
this.depositAccountTransactionDataValidator.validatePayment(command);
final Map<String, Object> changes = new LinkedHashMap<>();
final PaymentDetail paymentDetail = this.paymentDetailWritePlatformService.createAndPersistPaymentDetail(command, changes);
// Payment intentionally spans both states -- unlike Close (Matured only) and Premature Close
// (Active only) -- but it must still be one of those two. Without this, a closed or
// pre-maturely-closed deposit could be reopened: the reinvest branches put the account back to
// ACTIVE and never reach the close()/prematureClosure() calls that would otherwise reject it.
final FixedDepositAccount account = (FixedDepositAccount) this.depositAccountAssembler.assembleFrom(savingsId,
DepositAccountType.FIXED_DEPOSIT);
if (!account.isActive() && !account.isMatured()) {
throw new GeneralPlatformDomainRuleException("error.msg.fixeddeposit.payment.account.not.active.or.matured",
"Payment can only be processed on an active or matured fixed deposit account.", savingsId);
}
checkClientOrGroupActive(account);
this.depositAccountDomainService.handleFDAccountPayment(account, paymentDetail, user, command, changes);
// Reinvest Principal Only opens a second account and the domain method withdrew the principal out
// of this one against the transfer-suspense GL. Post the matching credit leg so the funds actually
// land in the new deposit -- same sequence updateMaturityDetails() uses for the automated path.
if (changes.get("reinvestedDepositId") != null) {
final Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
final Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(command.extractLocale());
final LocalDate closedOnDate = command.localDateValueOfParameterNamed(SavingsApiConstants.closedOnDateParamName);
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, closedOnDate, amountForDeposit.getAmount(), null,
isInternalTransferLeg);
}
final String noteText = command.stringValueOfParameterNamed("note");
if (StringUtils.isNotBlank(noteText)) {
final Note note = Note.savingNote(account, noteText);
changes.put("note", noteText);
this.noteRepository.save(note);
}
return new CommandProcessingResultBuilder() //
.withEntityId(savingsId) //
.withOfficeId(account.officeId()) //
.withClientId(account.clientId()) //
.withGroupId(account.groupId()) //
.withSavingsId(savingsId) //
.with(changes)//
.build();
}
@Override
public CommandProcessingResult prematureCloseRDAccount(final Long savingsId, final JsonCommand command) {
final AppUser user = this.context.authenticatedUser();
@@ -1359,18 +1455,37 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
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.
markWithdrawalsAsAlreadyPosted(account, existingTransactionIds);
if (changes.get("reinvestedDepositId") != null) {
Long reinvestedDepositId = (Long) changes.get("reinvestedDepositId");
Money amountForDeposit = account.activateWithBalance();
final FixedDepositAccount reinvestAccount = (FixedDepositAccount) this.depositAccountAssembler
.assembleFrom(reinvestedDepositId, DepositAccountType.FIXED_DEPOSIT);
// activateWithBalance() always returns zero here -- it exists to stop a manually
// opened FD from auto-crediting itself at activation (funding is supposed to be a
// separate, clerk-posted deposit). A reinvestment isn't that: the balance was just
// withdrawn from the matured account and needs to land in the new one as part of
// this same automated closure, so it has to come from what reInvest() actually
// carried over onto the new account, not from that unrelated activation guard.
Money amountForDeposit = Money.of(reinvestAccount.getCurrency(), reinvestAccount.getDepositAmount());
Money activationChargeAmount = getActivationCharge(reinvestAccount);
if (activationChargeAmount.isGreaterThanZero()) {
payActivationCharge(reinvestAccount);
amountForDeposit = amountForDeposit.plus(activationChargeAmount);
}
// Matching internal-transfer leg for the withdrawal the closure just made on the
// matured account: the funds never touch cash, so this posts against the
// transfer-suspense GL instead of debiting cash/vault.
final boolean isInternalTransferLeg = true;
this.depositAccountDomainService.handleFDDeposit(reinvestAccount, fmt, fdAccount.maturityDate(),
amountForDeposit.getAmount(), null);
amountForDeposit.getAmount(), null, isInternalTransferLeg);
}
}
} else if (depositAccountType.isRecurringDeposit()) {
@@ -1387,6 +1502,19 @@ public class DepositAccountWritePlatformServiceJpaRepositoryImpl implements Depo
existingReversedTransactionIds.addAll(account.findExistingReversedTransactionIds());
}
/**
* Marks the account's withdrawal (and withdrawal-fee) transactions as already accounted for, so a
* subsequent postJournalEntries() built on an older snapshot does not post them again. Used after a
* maturity closure, whose withdrawal leg posts its own journal entries.
*/
private void markWithdrawalsAsAlreadyPosted(final SavingsAccount account, final Set<Long> existingTransactionIds) {
for (final SavingsAccountTransaction transaction : account.getTransactions()) {
if (transaction.getId() != null && (transaction.isWithdrawal() || transaction.isWithdrawalFee())) {
existingTransactionIds.add(transaction.getId());
}
}
}
private void postJournalEntries(final SavingsAccount savingsAccount, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds) {
@@ -220,10 +220,16 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
this.savingsAccountWritePlatformService.releaseAmount(savingsId, pendingEdit.getHoldTransactionId());
}
// 2) Post the real charge(s) for newly-enabled facilities, today.
// 2) Post the real charge(s) for newly-enabled facilities, today -- and post the matching
// GL journal entries for those charge transactions (collectCharges() only books them
// against the account itself; it does not touch the general ledger).
if (!newlyEnabledFacilityTypes.isEmpty()) {
final Set<Long> existingTransactionIds = new HashSet<>(account.findExistingTransactionIds());
final Set<Long> existingReversedTransactionIds = new HashSet<>(account.findExistingReversedTransactionIds());
final var chargesAdded = this.facilityChargeAutoApplyService.applyChargesForFacilities(account, newlyEnabledFacilityTypes);
this.facilityChargeAutoApplyService.collectCharges(account, chargesAdded, DateUtils.getBusinessLocalDate());
this.savingsAccountWritePlatformService.postJournalEntriesForSavingsAccount(account, existingTransactionIds,
existingReversedTransactionIds, false);
}
// 3) Apply the facility-config toggles to the real row.
@@ -97,7 +97,6 @@ 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.service.FacilityChargeAutoApplyService;
import org.apache.fineract.portfolio.savings.SavingsAccountTransactionType;
import org.apache.fineract.portfolio.savings.SavingsApiConstants;
import org.apache.fineract.portfolio.savings.SavingsTransactionBooleanValues;
@@ -167,7 +166,6 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
private final GSIMRepositoy gsimRepository;
private final SavingsAccountInterestPostingService savingsAccountInterestPostingService;
private final ErrorHandler errorHandler;
private final FacilityChargeAutoApplyService facilityChargeAutoApplyService;
private final DenominationBreakdownWriter denominationBreakdownWriter;
@Transactional
@@ -203,23 +201,9 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Validate minimum opening balance
BigDecimal openingBalanceForValidation = BigDecimal.ZERO;
if (command.parameterExists("openingBalance")) {
final BigDecimal ob = command.bigDecimalValueOfParameterNamed("openingBalance");
if (ob != null) openingBalanceForValidation = ob;
}
final BigDecimal minRequiredOpening = account.getMinRequiredOpeningBalance();
if (minRequiredOpening != null && minRequiredOpening.compareTo(BigDecimal.ZERO) > 0
&& openingBalanceForValidation.compareTo(minRequiredOpening) < 0) {
final List<ApiParameterError> errors = new ArrayList<>();
errors.add(ApiParameterError.parameterError(
"openingBalance",
"Opening balance (" + openingBalanceForValidation + ") is less than minimum required opening balance (" + minRequiredOpening + ")",
"validation.msg.savingsaccount.openingBalance.less.than.min.required",
openingBalanceForValidation, minRequiredOpening));
throw new PlatformApiDataValidationException(errors);
}
// Minimum required opening balance is not enforced -- activation must never be blocked
// for not meeting it; the account can be funded afterwards by whatever deposit the
// customer brings (cash or a transfer), on their own timeline.
checkClientOrGroupActive(account);
final Set<Long> existingTransactionIds = new HashSet<>();
@@ -236,14 +220,10 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
final Locale locale = command.extractLocale();
final DateTimeFormatter fmt = DateTimeFormatter.ofPattern(command.dateFormat()).withLocale(locale);
processPostActiveActions(account, fmt, existingTransactionIds, existingReversedTransactionIds);
// Auto-apply facility charges based on product facility config
facilityChargeAutoApplyService.applyFacilityChargesOnActivation(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Collect any newly added facility activation charges (after flush so amountOutstanding is set)
// payCharge() internally adds transactions to account.transactions via handleChargeTransactions
// Do NOT add to existingTransactionIds — postJournalEntries must include them for GL posting
facilityChargeAutoApplyService.collectActivationCharges(account);
this.savingAccountRepositoryWrapper.saveAndFlush(account);
// Activation posts no transaction of its own -- digital-facility charges are no longer
// auto-applied and auto-collected here. Whatever facilities are turned on later get
// charged (if at all) through the normal charge/collection flow at that time, not as a
// side effect of activation.
}
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, false);
@@ -1521,6 +1501,12 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
this.journalEntryWritePlatformService.createJournalEntriesForSavings(accountingBridgeData);
}
@Override
public void postJournalEntriesForSavingsAccount(final SavingsAccount account, final Set<Long> existingTransactionIds,
final Set<Long> existingReversedTransactionIds, final boolean backdatedTxnsAllowedTill) {
postJournalEntries(account, existingTransactionIds, existingReversedTransactionIds, backdatedTxnsAllowedTill);
}
@Override
public CommandProcessingResult inactivateCharge(final Long savingsAccountId, final Long savingsAccountChargeId) {
@@ -1690,6 +1676,15 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusInactive(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// Same snapshot-vs-execution gap as escheat() below: the dormant-accounts batch selects
// pending-inactive ids from a query that requires status = ACTIVE, but the account can close
// through another path (e.g. deposit maturity) before this call runs. Re-deriving accounting
// off the full transaction list at that point re-posts entries that closure already posted.
if (!account.isActive()) {
log.warn("Skipping setSubStatusInactive for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -1701,6 +1696,11 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void setSubStatusDormant(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
if (!account.isActive()) {
log.warn("Skipping setSubStatusDormant for savings account {} -- no longer active (already closed by another process)",
savingsId);
return;
}
account.setSubStatusDormant();
this.savingAccountRepositoryWrapper.saveAndFlush(account);
}
@@ -1708,6 +1708,16 @@ public class SavingsAccountWritePlatformServiceJpaRepositoryImpl implements Savi
@Override
public void escheat(Long savingsId) {
final SavingsAccount account = this.savingAccountAssembler.assembleFrom(savingsId, false);
// The dormant-accounts batch selects escheat-eligible ids from a snapshot query, so an
// account can legitimately close through another path (e.g. deposit maturity) in the gap
// between that selection and this call. escheat() unconditionally forces status/sub-status
// to CLOSED/ESCHEAT and re-derives accounting off the account's full transaction list, so
// running it against an already-closed account both mislabels the closure reason and
// re-posts journal entries for transactions that were already posted by the real closure.
if (!account.isActive()) {
log.warn("Skipping escheat for savings account {} -- no longer active (already closed by another process)", savingsId);
return;
}
final Set<Long> existingTransactionIds = new HashSet<>();
final Set<Long> existingReversedTransactionIds = new HashSet<>();
updateExistingTransactionsDetails(account, existingTransactionIds, existingReversedTransactionIds);
@@ -463,8 +463,7 @@ public class SavingsConfiguration {
EntityDatatableChecksWritePlatformService entityDatatableChecksWritePlatformService, AppUserRepositoryWrapper appuserRepository,
StandingInstructionRepository standingInstructionRepository, BusinessEventNotifierService businessEventNotifierService,
GSIMRepositoy gsimRepository, SavingsAccountInterestPostingService savingsAccountInterestPostingService,
ErrorHandler errorHandler, FacilityChargeAutoApplyService facilityChargeAutoApplyService,
DenominationBreakdownWriter denominationBreakdownWriter) {
ErrorHandler errorHandler, DenominationBreakdownWriter denominationBreakdownWriter) {
return new SavingsAccountWritePlatformServiceJpaRepositoryImpl(context, fromApiJsonDeserializer, savingAccountRepositoryWrapper,
staffRepository, savingsAccountTransactionRepository, savingAccountAssembler, savingsAccountTransactionDataValidator,
savingsAccountChargeDataValidator, paymentDetailWritePlatformService, journalEntryWritePlatformService,
@@ -472,7 +471,7 @@ public class SavingsConfiguration {
chargeRepository, savingsAccountChargeRepository, holidayRepository, workingDaysRepository, configurationDomainService,
depositAccountOnHoldTransactionRepository, entityDatatableChecksWritePlatformService, appuserRepository,
standingInstructionRepository, businessEventNotifierService, gsimRepository, savingsAccountInterestPostingService,
errorHandler, facilityChargeAutoApplyService, denominationBreakdownWriter);
errorHandler, denominationBreakdownWriter);
}
@Bean
@@ -688,6 +688,13 @@ fineract.openproject.api-token=${FINERACT_OPENPROJECT_API_TOKEN:57dd9f933f113c72
# │ DIP/MNRL provider. Do not ship the mock. │
# └──────────────────────────────────────────────────────────────────────────┘
fineract.mobile-lookup.base-url=${FINERACT_MOBILE_LOOKUP_BASE_URL:http://127.0.0.1:8090}
# Set to false to skip the DIP/MNRL check entirely (no HTTP call at all, no need to run the mock
# service) -- every number is then logged as CLEAN but stamped "LOOKUP_DISABLED_BY_CONFIG" so it is
# never mistaken for a real pass.
# Currently OFF by default for local work so nobody needs the mock service running. Set
# FINERACT_MOBILE_LOOKUP_ENABLED=true (or flip the default below) before UAT/production, where the
# check must actually run.
fineract.mobile-lookup.enabled=${FINERACT_MOBILE_LOOKUP_ENABLED:false}
# ── Login OTP (2FA) — uses LincPay credentials above ─────────────────────────
fineract.security.otp.lincpay.generate-path=${FINERACT_OTP_LINCPAY_GENERATE_PATH:/mobile/generate-otp}
@@ -558,4 +558,13 @@
<include file="parts/0488_add_voucher_exception_resolution.xml" relativeToChangelogFile="true"/>
<include file="parts/0489_create_branch_day_register.xml" relativeToChangelogFile="true"/>
<include file="parts/0490_seed_bank_profit_non_distributable_gl.xml" relativeToChangelogFile="true"/>
<include file="parts/0470_add_reinvested_from_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0471_add_fd_account_number_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0472_add_renewal_cycle_no_to_deposit_term.xml" relativeToChangelogFile="true"/>
<include file="parts/0473_create_fd_renewal_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0474_add_is_active_to_interest_rate_chart.xml" relativeToChangelogFile="true"/>
<include file="parts/0475_create_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0476_add_senior_citizen_rate_to_account_chart_slab.xml" relativeToChangelogFile="true"/>
<include file="parts/0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history.xml" relativeToChangelogFile="true"/>
<include file="parts/0478_add_fd_payment_permission.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- On "Re-Invest Maturity Amount"/"Re-Invest Principal Only" closure, a brand new FD/RD account is
auto-created with no visible link back to the matured account it came from, so staff had no way
to trace one from the other. This column, set on the NEW account, points at the OLD one; the OLD
account's own "reinvested into" side is derived by looking up whichever row has this account's id
here, so only one column is needed to track the relationship in both directions. -->
<changeSet id="0470_add_reinvested_from_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="reinvested_from_savings_id"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="reinvested_from_savings_id" type="BIGINT"/>
</addColumn>
<createIndex indexName="reinvested_from_savings_id" tableName="m_deposit_account_term_and_preclosure">
<column name="reinvested_from_savings_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="reinvested_from_savings_id"
baseTableName="m_deposit_account_term_and_preclosure"
constraintName="m_deposit_account_term_and_preclosure_reinvested_from_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,24 @@
<?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">
<!-- FD renewal ("Re-Invest") always opens a brand new account with its own account_no, since
account_no is unique and the matured account keeps its own row for audit history. Customers
and staff still expect to recognise "their FD" by one number across renewals though, so this
column carries the ORIGINAL account's account_no forward onto every account created by
reinvesting it (and its reinvestments, and so on). Left null on an account that has never been
reinvested; the read side falls back to that account's own account_no in that case, so only
accounts past their first renewal need this populated. -->
<changeSet id="0471_add_fd_account_number_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="fd_account_number"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="fd_account_number" type="VARCHAR(20)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,23 @@
<?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">
<!-- Which renewal cycle of one logical FD this account row represents: 0 for the account the
customer originally opened, 1 for the first renewal, and so on. Together with
fd_account_number (the stable customer-facing number) this turns the chain of per-term
account rows into an explicit "one FD, many cycles" model, so statements and the account
list can present a single FD with numbered terms instead of unrelated accounts.
Defaults to 0 so every pre-existing account reads as its own first cycle. -->
<changeSet id="0472_add_renewal_cycle_no_to_deposit_term" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_deposit_account_term_and_preclosure" columnName="renewal_cycle_no"/>
</not>
</preConditions>
<addColumn tableName="m_deposit_account_term_and_preclosure">
<column name="renewal_cycle_no" type="INT" defaultValueNumeric="0"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,81 @@
<?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">
<!-- Audit trail of FD renewals, one row per term of one logical FD.
A renewal opens each term as its own deposit instance (its own m_savings_account row, so that
term keeps its own immutable ledger, interest postings and GL entries), while the customer
keeps seeing one FD number. That leaves the renewal EVENT itself unrecorded: how much
interest the previous term earned, what it matured at, how much was carried forward, at what
rate, whether the renewal was automatic or done by a teller, and who did it. Deriving that by
joining account rows loses the intent; this table records it explicitly.
fd_account_number is the stable customer-facing number shared by every term, so one FD's
whole history is one indexed lookup. -->
<changeSet id="0473_create_fd_renewal_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_account_number" type="VARCHAR(20)">
<constraints nullable="false"/>
</column>
<column name="original_savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="savings_account_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="previous_savings_account_id" type="BIGINT"/>
<column name="previous_renewal_id" type="BIGINT"/>
<column name="renewal_no" type="INT">
<constraints nullable="false"/>
</column>
<column name="previous_principal" type="DECIMAL(19,6)"/>
<column name="interest_earned" type="DECIMAL(19,6)"/>
<column name="maturity_amount" type="DECIMAL(19,6)"/>
<column name="renewed_amount" type="DECIMAL(19,6)"/>
<column name="interest_rate" type="DECIMAL(19,6)"/>
<column name="start_date" type="DATE"/>
<column name="maturity_date" type="DATE"/>
<column name="tenure" type="INT"/>
<column name="tenure_frequency_enum" type="SMALLINT"/>
<column name="renewal_type" type="VARCHAR(10)"/>
<column name="renewal_status" type="VARCHAR(20)"/>
<column name="renewed_on" type="DATE"/>
<column name="created_by_id" type="BIGINT"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_history_fd_account_number" tableName="m_fd_renewal_history">
<column name="fd_account_number"/>
</createIndex>
<createIndex indexName="idx_fd_renewal_history_savings_account_id" tableName="m_fd_renewal_history">
<column name="savings_account_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="original_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_original_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
<addForeignKeyConstraint baseColumnNames="previous_savings_account_id"
baseTableName="m_fd_renewal_history"
constraintName="m_fd_renewal_history_prev_account_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="RESTRICT" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_savings_account" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,40 @@
<?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">
<!-- Explicit lifecycle flag on a product's interest-rate chart.
Which chart prices a deposit was previously inferred purely from its from/end dates, so
retiring a rate version meant remembering to set an end date on it, and a version left
open-ended was treated as valid only up to today. Staff think in terms of "these are the
rates we currently offer", so the chart now says so directly and date ranges stay as the
historical record.
Defaults to true so every existing chart keeps working; the changeset then retires any chart
whose end date has already passed, since those were superseded by definition.
Deliberately on m_interest_rate_chart only, not on the shared InterestRateChartFields
embeddable, which is also embedded into m_savings_account_interest_rate_chart (a per-account
snapshot that has no lifecycle of its own). -->
<changeSet id="0474_add_is_active_to_interest_rate_chart" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_interest_rate_chart" columnName="is_active"/>
</not>
</preConditions>
<addColumn tableName="m_interest_rate_chart">
<column name="is_active" type="BOOLEAN" defaultValueBoolean="true">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
<changeSet id="0474_retire_expired_interest_rate_charts" author="CBS" runOnChange="false">
<update tableName="m_interest_rate_chart">
<column name="is_active" valueBoolean="false"/>
<where>end_date IS NOT NULL AND end_date &lt; CURRENT_DATE</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,50 @@
<?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">
<!-- Archives the FULL tenure-by-rate chart of a term that is about to be superseded by an in-place FD
renewal, not just the single effective rate m_fd_renewal_history already records.
renewInPlace() reuses the same account row across renewals, so the account's own interest-rate
chart (m_savings_account_interest_rate_chart / _slab) is overwritten in place at every renewal;
the closing term's full rate card would otherwise be gone with no way to answer "what did the
whole chart look like during term 2", only "what rate applied". One row per slab, linked to the
m_fd_renewal_history row for the term the chart belonged to. -->
<changeSet id="0475_create_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<tableExists tableName="m_fd_renewal_chart_slab_history"/>
</not>
</preConditions>
<createTable tableName="m_fd_renewal_chart_slab_history">
<column name="id" type="BIGINT" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="fd_renewal_history_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="chart_name" type="VARCHAR(100)"/>
<column name="description" type="VARCHAR(200)"/>
<column name="period_type_enum" type="SMALLINT"/>
<column name="from_period" type="INT"/>
<column name="to_period" type="INT"/>
<column name="amount_range_from" type="DECIMAL(19,6)"/>
<column name="amount_range_to" type="DECIMAL(19,6)"/>
<column name="annual_interest_rate" type="DECIMAL(19,6)"/>
<column name="currency_code" type="VARCHAR(3)"/>
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP"/>
</createTable>
<createIndex indexName="idx_fd_renewal_chart_slab_history_renewal_id" tableName="m_fd_renewal_chart_slab_history">
<column name="fd_renewal_history_id"/>
</createIndex>
<addForeignKeyConstraint baseColumnNames="fd_renewal_history_id"
baseTableName="m_fd_renewal_chart_slab_history"
constraintName="m_fd_renewal_chart_slab_history_renewal_ibfk"
deferrable="false" initiallyDeferred="false" onDelete="CASCADE" onUpdate="RESTRICT"
referencedColumnNames="id" referencedTableName="m_fd_renewal_history" validate="true"/>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- The senior-citizen premium a slab of an FD's own chart carries, alongside its standard rate.
m_savings_account_interest_rate_slab is populated from InterestRateChartSlabFields, a shared
embeddable also used by the product-level chart table (m_interest_rate_slab), which has no
senior-citizen concept at all; only the CBS base interest-rate master does. Adding the column
to the shared embeddable would have forced it onto that product table too, so it is added here
instead, directly on this table only, and populated by application code independently of the
embeddable. Column stays null for every Savings/RD account and for any FD term priced off a
product chart rather than the base-rate master, which is the existing, unaffected behaviour for
both. -->
<changeSet id="0476_add_senior_citizen_rate_to_account_chart_slab" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_savings_account_interest_rate_slab" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_savings_account_interest_rate_slab">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,21 @@
<?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">
<!-- Carries the senior-citizen premium into the archived rate-chart history too, matching the column
just added to the live account chart in 0476, so a matured term's true historical premium remains
queryable rather than only ever being looked up against whatever the base rate card currently
publishes. -->
<changeSet id="0477_add_senior_citizen_rate_to_fd_renewal_chart_slab_history" author="CBS" runOnChange="false">
<preConditions onFail="MARK_RAN">
<not>
<columnExists tableName="m_fd_renewal_chart_slab_history" columnName="senior_citizen_rate"/>
</not>
</preConditions>
<addColumn tableName="m_fd_renewal_chart_slab_history">
<column name="senior_citizen_rate" type="DECIMAL(19,6)"/>
</addColumn>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,29 @@
<?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">
<!-- Permission for the new FD "Payment" command, mirroring PREMATURECLOSE_FIXEDDEPOSITACCOUNT's
grouping and maker-checker setting exactly, so it slots into the same role-assignment screen. -->
<changeSet id="0478_add_fd_payment_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT', 'FIXEDDEPOSITACCOUNT', 'PAYMENT', true
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT');
</sql>
</changeSet>
<!-- The checker half of the pair above. Because that row is can_maker_checker = true, switching the
global maker-checker setting on makes every FD Payment queue for approval, and approving one
requires the _CHECKER permission by name. Without this row that permission cannot be granted to
any role, so queued payments would be unapprovable by anyone but a super user. Every other
maker-checker FD permission (APPROVE, CLOSE, PREMATURECLOSE, POSTINTEREST, ...) has this twin. -->
<changeSet id="0478_add_fd_payment_checker_permission" author="CBS" runOnChange="false">
<sql>
INSERT INTO m_permission (grouping, code, entity_name, action_name, can_maker_checker)
SELECT 'transaction_savings', 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER', 'FIXEDDEPOSITACCOUNT', 'PAYMENT_CHECKER', false
WHERE NOT EXISTS (SELECT 1 FROM m_permission WHERE code = 'PAYMENT_FIXEDDEPOSITACCOUNT_CHECKER');
</sql>
</changeSet>
</databaseChangeLog>