Merge remote-tracking branch 'origin/dev-latest' into dev-latest
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 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-10-05 12:43:38 +05:30
32 changed files with 688 additions and 141 deletions
@@ -477,6 +477,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product
accountMappingDetails.put(SavingProductAccountingDataParams.SAVINGS_CONTROL.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.TRANSFERS_SUSPENSE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.TRANSFERS_SUSPENSE.getValue(), glAccountData);
} else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_PAYABLE)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_PAYABLE.getValue(), glAccountData);
// Income
} else if (glAccountForSavings.equals(CashAccountsForSavings.INCOME_FROM_FEES)) {
accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_FEES.getValue(), glAccountData);
@@ -270,6 +270,7 @@ public final class AccountingConstants {
INCOME_FROM_INTEREST(12), //
LOSSES_WRITTEN_OFF(13), //
ESCHEAT_LIABILITY(14), //
INTEREST_PAYABLE(17), //
/**
* Where the paise dropped from a cash closure are absorbed.
*
@@ -137,7 +137,16 @@ public class CommandSourceService {
String permission = commandSource.getPermissionCode();
boolean isMakerChecker = configurationDomainService.isMakerCheckerEnabledForTask(permission);
if (isMakerChecker || result.isRollbackTransaction()) {
if (isApprovedByChecker || isSuperUserBypassAllowed(commandSource, user)) {
// isApprovedByChecker is only ever true here for a genuine checker approval
// (PortfolioCommandSourceWritePlatformServiceImpl.approveEntry(), which has already run
// validateMakerCheckerTransaction() and confirmed the approver is not the maker) or a user
// editing their own account details (CommandWrapper.isChangeOfOwnUserDetails() - maker-checker
// genuinely does not apply there). On the ORIGINAL submission this is always false and `user`
// here is always the maker themselves, so there is deliberately no CHECKER_SUPER_USER (or any
// other) bypass at this point: holding CHECKER_SUPER_USER lets a user approve someone ELSE's
// request without needing the specific <TASK>_CHECKER permission, it must never let a maker
// auto-approve their own submission just by holding it.
if (isApprovedByChecker) {
commandSource.markAsChecked(user);
} else {
if (commandSource.isSanitized()) {
@@ -151,25 +160,6 @@ public class CommandSourceService {
return result;
}
private boolean isSuperUserBypassAllowed(CommandSource commandSource, AppUser user) {
if (!user.isCheckerSuperUser()) {
return false;
}
if ("GLACCOUNT".equalsIgnoreCase(commandSource.getEntityName())
|| "CBSBASEINTERESTRATE".equalsIgnoreCase(commandSource.getEntityName())
|| "CHARGE".equalsIgnoreCase(commandSource.getEntityName())
|| "LOANPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SAVINGSPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "CURRENTPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "SHAREPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "FIXEDDEPOSITPRODUCT".equalsIgnoreCase(commandSource.getEntityName())
|| "USER".equalsIgnoreCase(commandSource.getEntityName())
|| "ROLE".equalsIgnoreCase(commandSource.getEntityName())) {
return false;
}
return true;
}
private void sanitizeJson(@NonNull CommandSource commandSource, Set<String> sanitizeKeys) {
if (sanitizeKeys == null || sanitizeKeys.isEmpty()) {
return;
@@ -48,6 +48,12 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
private final FromJsonHelper fromApiJsonHelper;
private final CommandProcessingService processAndLogCommandService;
private final SchedulerJobRunnerReadService schedulerJobRunnerReadService;
// No longer consulted by validateMakerCheckerTransaction() below: the maker-identity rule (a user
// can never approve their own submission) is now unconditional and isSameMakerCheckerEnabled() no
// longer overrides it. Left injected rather than removed - enable-same-maker-checker is still a
// live, independently-configurable global switch (System -> Configurations); deleting this field,
// and by extension deciding that switch should stop existing altogether, is a separate decision
// from closing this specific self-approval loophole.
private final ConfigurationDomainService configurationService;
@Override
@@ -123,7 +129,14 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
AppUser appUser = this.context.authenticatedUser();
String permissionCode = commandSource.getPermissionCode();
appUser.validateHasCheckerPermissionTo(permissionCode);
if (!configurationService.isSameMakerCheckerEnabled() && !appUser.isCheckerSuperUser()) {
// Maker-identity rule: the user who submitted a command may never be the one who approves
// (or rejects) it - this is a fixed platform rule, not overridable by CHECKER_SUPER_USER (that
// permission still lets its holder action a DIFFERENT user's request without needing the
// specific <TASK>_CHECKER permission - see validateHasCheckerPermissionTo() above - it just no
// longer lets them action their own) and not overridable by the enable-same-maker-checker
// global switch (isSameMakerCheckerEnabled() is deliberately no longer consulted here; that
// configuration is left in place and unused for this decision rather than removed, since
// deleting a still-configurable global setting is a separate decision from this one).
AppUser maker = commandSource.getMaker();
if (maker == null) {
throw new UnsupportedCommandException(permissionCode, "Maker user is missing.");
@@ -131,7 +144,6 @@ public class PortfolioCommandSourceWritePlatformServiceImpl implements Portfolio
if (Objects.equals(appUser.getId(), maker.getId())) {
throw new UnsupportedCommandException(permissionCode, "Can not be checked by the same user.");
}
}
return commandSource;
}
@@ -32,7 +32,8 @@ public enum DepositAccountOnClosureType {
TRANSFER_TO_SAVINGS(200, "depositAccountClosureType.transferToSavings"), //
REINVEST_PRINCIPAL_AND_INTEREST(300, "depositAccountClosureType.reinvestPrincipalAndInterest"), //
REINVEST_PRINCIPAL_ONLY(400, "depositAccountClosureType.reinvestPrincipalOnly"), //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"); //
PARTIAL_REINVEST(500, "depositAccountClosureType.partialReinvest"), //
TRANSFER_TO_GL(600, "depositAccountClosureType.transferToGl"); //
private final Integer value;
private final String code;
@@ -53,6 +54,7 @@ public enum DepositAccountOnClosureType {
case 300 -> REINVEST_PRINCIPAL_AND_INTEREST;
case 400 -> REINVEST_PRINCIPAL_ONLY;
case 500 -> PARTIAL_REINVEST;
case 600 -> TRANSFER_TO_GL;
default -> INVALID;
};
}
@@ -91,6 +93,17 @@ public enum DepositAccountOnClosureType {
return this.equals(PARTIAL_REINVEST);
}
/**
* Pays the closure proceeds into a GL account the maker picks, instead of a savings account or cash.
* Only the fixed-deposit premature-closure handler implements it -- see
* DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure; every other closure path rejects it
* in DepositAccountTransactionDataValidator#validateClosing rather than falling through to the
* terminal "pay the balance out in cash" branch.
*/
public boolean isTransferToGl() {
return this.equals(TRANSFER_TO_GL);
}
// TODO: do we really need this?!?
public static Object[] integerValues() {
return Arrays.stream(values()).filter(value -> !INVALID.equals(value)).map(value -> value.value).toList().toArray();
@@ -212,6 +212,9 @@ public final class DepositsApiConstants {
public static final String onAccountClosureIdParamName = "onAccountClosureId";
public static final String transferDescriptionParamName = "transferDescription";
public static final String toSavingsAccountIdParamName = "toSavingsAccountId";
// Destination GL account for DepositAccountOnClosureType.TRANSFER_TO_GL -- the closure proceeds are
// credited straight to this GL instead of a savings account or cash.
public static final String toGlAccountIdParamName = "toGlAccountId";
public static final String reinvestAmountParamName = "reinvestAmount";
// Lets a maker waive the product's configured pre-closure penalty for one premature closure --
// see FixedDepositAccount#waivePreClosurePenaltyForThisRequest for why this is a transient
@@ -249,7 +252,7 @@ public final class DepositsApiConstants {
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
SavingsApiConstants.taxGroupIdParamName));
SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
@@ -387,6 +390,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
fixedDepositRequestData.add("feeToIncomeAccountMappings");
fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
fixedDepositRequestData.add("otherGlMappingsMap");
return fixedDepositRequestData;
}
@@ -498,6 +503,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
recurringDepositRequestData.add("feeToIncomeAccountMappings");
recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
recurringDepositRequestData.add("otherGlMappingsMap");
return recurringDepositRequestData;
}
@@ -801,6 +801,10 @@ public final class SavingsEnumerations {
optionData = new EnumOptionData(DepositAccountOnClosureType.PARTIAL_REINVEST.getValue().longValue(),
DepositAccountOnClosureType.PARTIAL_REINVEST.getCode(), "Partial Re-Invest");
break;
case TRANSFER_TO_GL:
optionData = new EnumOptionData(DepositAccountOnClosureType.TRANSFER_TO_GL.getValue().longValue(),
DepositAccountOnClosureType.TRANSFER_TO_GL.getCode(), "Transfer to GL Account");
break;
}
return optionData;
}
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository;
@@ -83,6 +84,7 @@ import org.apache.fineract.portfolio.shareaccounts.data.ShareAccountTransactionE
import org.springframework.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate;
@Slf4j
@RequiredArgsConstructor
public class AccountingProcessorHelper {
@@ -662,16 +664,25 @@ public class AccountingProcessorHelper {
final GLAccount payableAccount = Optional
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAYABLE_MAPPING)))
.or(() -> Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAYABLE_MAPPING, "Fixed Deposit Interest Payable", "Interest Payable", "Interest Payable Account")))
.orElse(controlAccount);
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING))
final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId,
SAVINGS_INTEREST_PAID_MAPPING, "Fixed Deposit Interest Paid", "Interest Paid", "Interest Paid Account"))
.orElse(payableAccount);
log.info("createInterestPostingJournalEntriesForSavings: savingsProductId={}, payableAccount=[{} - {}], paidAccount=[{} - {}], controlAccount=[{} - {}], interestExpense=[{} - {}], amount={}, isReversal={}",
savingsProductId, payableAccount.getId(), payableAccount.getName(), paidAccount.getId(), paidAccount.getName(),
controlAccount.getId(), controlAccount.getName(), interestOnSavingsAccount.getId(), interestOnSavingsAccount.getName(),
amount, isReversal);
// A product that names no payable falls back to Savings Control above. The chain below would
// then open with Dr Savings Control / Cr Interest On Savings and close with its exact
// reverse, the two cancelling: the expense never charged and the member never credited. Such
// a product has no liability to draw down, so it keeps the single pair it always posted.
if (payableAccount.getId().equals(controlAccount.getId())) {
log.warn("createInterestPostingJournalEntriesForSavings: payableAccount == controlAccount [id={}], posting standard 2-leg entry",
payableAccount.getId());
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal);
return;
@@ -697,28 +708,34 @@ public class AccountingProcessorHelper {
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO);
log.info("createInterestPostingJournalEntriesForSavings: alreadyAccrued={}, shortfall={}, payableIsLiability={}, isReversal={}",
alreadyAccrued, shortfall, payableIsLiability, isReversal);
if (!isReversal && payableIsLiability && shortfall.compareTo(BigDecimal.ZERO) > 0) {
// Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 1 [Dr {} / Cr {} = {}]",
interestOnSavingsAccount.getName(), payableAccount.getName(), shortfall);
// Stage 1: Dr Interest On Savings (the expense) / Cr Payable - the accrual that should have run.
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
shortfall, false);
}
// Out of the payable, into the interest-paid account the product names.
postSavingsLeg(office, currencyCode, payableAccount, interestOnSavingsAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// A separate paid account, where the product names one, sits between interest-paid and the
// member; otherwise the payout leaves straight from interest-paid.
final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) {
postSavingsLeg(office, currencyCode, interestOnSavingsAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
log.info("createInterestPostingJournalEntriesForSavings: posting Stage 2 [Dr {} / Cr {} = {}] and Stage 3 [Dr {} / Cr {} = {}]",
payableAccount.getName(), paidAccount.getName(), amount, paidAccount.getName(), controlAccount.getName(), amount);
// Stage 2: Out of the payable, into the interest-paid transit/clearing account
postSavingsLeg(office, currencyCode, payableAccount, paidAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
// Stage 3: Out of the interest-paid account, into the member's control account
postSavingsLeg(office, currencyCode, paidAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
} else {
log.info("createInterestPostingJournalEntriesForSavings: no separate paidAccount, posting direct payout [Dr {} / Cr {} = {}]",
payableAccount.getName(), controlAccount.getName(), amount);
// Payout directly from payable into member's control account
postSavingsLeg(office, currencyCode, payableAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
// Into the member's own balance.
final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
}
/**
@@ -792,15 +809,14 @@ public class AccountingProcessorHelper {
return balance == null ? BigDecimal.ZERO : balance;
}
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) {
if (savingsProductId == null) {
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
return null;
}
// The column holds an empty string on a product saved with no named mappings, and casting
// that to jsonb is an error rather than an empty document - so the cast is done inside a
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE
// clause in any order, so guarding at the same query level as the cast would only work by
// luck.
for (final String accountName : accountNames) {
if (accountName == null) {
continue;
}
final List<Long> found = jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
@@ -816,10 +832,14 @@ public class AccountingProcessorHelper {
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE m.entry ->> 'accountName' = ?
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
LIMIT 1
""", Long.class, savingsProductId, accountName);
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null);
if (!found.isEmpty()) {
return glAccountRepository.findById(found.get(0)).orElse(null);
}
}
return null;
}
/**
@@ -78,7 +78,8 @@ public class DepositAccountTransactionDataValidator {
DepositsApiConstants.noteParamName, onAccountClosureIdParamName, paymentTypeIdParamName,
transactionAccountNumberParamName, checkNumberParamName, routingCodeParamName, receiptNumberParamName,
bankNumberParamName, DepositsApiConstants.transferDescriptionParamName, toSavingsAccountIdParamName,
DepositsApiConstants.waivePreClosurePenaltyParamName, DepositsApiConstants.prematureClosureReasonIdParamName));
DepositsApiConstants.toGlAccountIdParamName, DepositsApiConstants.waivePreClosurePenaltyParamName,
DepositsApiConstants.prematureClosureReasonIdParamName));
private static final Set<String> DEPOSIT_ACCOUNT_PRE_MATURE_CALCULATION_REQUEST_DATA_PARAMETERS = new HashSet<>(Arrays.asList(
DepositsApiConstants.localeParamName, DepositsApiConstants.dateFormatParamName, closedOnDateParamName,
@@ -247,6 +248,25 @@ public class DepositAccountTransactionDataValidator {
} else if (accountOnClosureType.isReinvest() && isPreMatureClose) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId)
.failWithCode("reinvest.not.allowed", "Re-Invest is not supported for account pre mature close");
} else if (accountOnClosureType.isTransferToGl()) {
// Only the FD premature-closure handler books the GL leg. Offering it anywhere else would
// fall through to that handler's terminal "withdraw the whole balance" case and silently pay
// the deposit out in cash, which is exactly what the partial.reinvest guard above prevents.
if (!isPreMatureClose || !DepositAccountType.FIXED_DEPOSIT.equals(depositAccountType)) {
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"transfer.to.gl.not.allowed",
"Transfer to a GL account is only supported for fixed-deposit premature closure");
}
final Long toGlAccountId = this.fromApiJsonHelper.extractLongNamed(DepositsApiConstants.toGlAccountIdParamName, element);
baseDataValidator.reset().parameter(DepositsApiConstants.toGlAccountIdParamName).value(toGlAccountId)
.cantBeBlankWhenParameterProvidedIs(onAccountClosureIdParamName, accountOnClosureType.getCode());
} else if (accountOnClosureType.isWithdarwDeposit() && isPreMatureClose) {
// Premature closure proceeds have to be traceable into a savings account, so the cash payout
// is not offered in the preview (see DepositAccountPreMatureCalculationPlatformServiceImpl)
// and is refused here as well -- otherwise a direct API call could still take the cash route.
baseDataValidator.reset().parameter(onAccountClosureIdParamName).value(onAccountClosureId).failWithCode(
"withdraw.deposit.not.allowed",
"Paying the deposit out in cash is not supported for account pre mature close -- transfer it to a savings account");
}
}
@@ -25,6 +25,8 @@ import static org.apache.fineract.portfolio.savings.DepositsApiConstants.toSavin
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.transferDescriptionParamName;
import static org.apache.fineract.portfolio.savings.DepositsApiConstants.waivePreClosurePenaltyParamName;
import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import java.math.BigDecimal;
import java.math.MathContext;
import java.time.LocalDate;
@@ -38,6 +40,7 @@ import java.util.Locale;
import java.util.Map;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingConstants;
import org.apache.fineract.accounting.journalentry.service.JournalEntryWritePlatformService;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormat;
import org.apache.fineract.infrastructure.accountnumberformat.domain.AccountNumberFormatRepositoryWrapper;
@@ -48,6 +51,7 @@ import org.apache.fineract.infrastructure.core.domain.ExternalId;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
import org.apache.fineract.infrastructure.core.service.ExternalIdFactory;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.portfolio.account.PortfolioAccountType;
import org.apache.fineract.portfolio.account.data.AccountTransferDTO;
import org.apache.fineract.portfolio.account.domain.AccountTransferType;
@@ -76,6 +80,7 @@ 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.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -96,6 +101,8 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
private final FixedDepositRenewalHistoryRepository fixedDepositRenewalHistoryRepository;
private final FixedDepositRenewalChartSlabHistoryRepository fixedDepositRenewalChartSlabHistoryRepository;
private final FdBaseInterestRateReadPlatformService fdBaseInterestRateReadPlatformService;
private final FromJsonHelper fromApiJsonHelper;
private final JdbcTemplate jdbcTemplate;
@Transactional
@Override
@@ -846,6 +853,20 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
toSavingsAccount, account, isRegularTransaction, isExceptionForBalanceCheck);
this.accountTransfersWritePlatformService.transferFunds(accountTransferDTO);
updateAlreadyPostedTransactions(existingTransactionIds, account);
} else if (closureType.isTransferToGl()) {
final Long toGlAccountId = command.longValueOfParameterNamed(DepositsApiConstants.toGlAccountIdParamName);
final BigDecimal proceeds = account.getAccountBalance();
// Declared as an account transfer so the withdrawal's own entry credits the Liability Transfer
// account rather than cash/vault -- nothing is paid over the counter here. The offset below then
// moves it out of there into the GL the maker picked, leaving Liability Transfer at nil and the
// net posting as Dr Savings Control / Cr chosen GL.
final boolean isInternalTransfer = true;
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, proceeds, paymentDetail, false,
isRegularTransaction, isInternalTransfer);
savingsTransactionId = withdrawal.getId();
updateAlreadyPostedTransactions(existingTransactionIds, account);
postClosureProceedsToGlAccount(account, toGlAccountId, proceeds, closedDate,
command.stringValueOfParameterNamed(transferDescriptionParamName));
} else {
final SavingsAccountTransaction withdrawal = this.handleWithdrawal(account, fmt, closedDate, account.getAccountBalance(),
paymentDetail, false, isRegularTransaction);
@@ -863,6 +884,81 @@ public class DepositAccountDomainServiceJpa implements DepositAccountDomainServi
return savingsTransactionId;
}
/**
* Moves a premature closure's proceeds out of Liability Transfer and into the GL account the maker picked:
* Dr the Liability Transfer account / Cr that GL account.
* <p>
* The withdrawal booked immediately before this was declared an account transfer, so the accounting engine
* has already credited Liability Transfer for the same amount -- this entry clears it, leaving the pair
* netting to Dr Savings Control / Cr the chosen GL, and Liability Transfer back at nil. Posted through the real
* createJournalEntry command rather than written straight to the ledger, so it reverses, reports and
* reconciles exactly like any other manual entry.
*/
private void postClosureProceedsToGlAccount(final SavingsAccount account, final Long toGlAccountId, final BigDecimal amount,
final LocalDate closedDate, final String description) {
final Long suspenseGlAccountId = liabilityTransferGlAccountId();
final JsonObject debitEntry = new JsonObject();
debitEntry.addProperty("glAccountId", suspenseGlAccountId);
debitEntry.addProperty("amount", amount);
final JsonArray debits = new JsonArray();
debits.add(debitEntry);
final JsonObject creditEntry = new JsonObject();
creditEntry.addProperty("glAccountId", toGlAccountId);
creditEntry.addProperty("amount", amount);
final JsonArray credits = new JsonArray();
credits.add(creditEntry);
final StringBuilder comments = new StringBuilder("Premature closure of fixed deposit ").append(account.getAccountNumber());
if (description != null && !description.isBlank()) {
comments.append(" -- ").append(description);
}
final String dateFormat = "dd MMMM yyyy";
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", account.officeId());
payload.addProperty("transactionDate", closedDate.format(DateTimeFormatter.ofPattern(dateFormat, Locale.ENGLISH)));
payload.addProperty("dateFormat", dateFormat);
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", account.getCurrency().getCode());
payload.addProperty("comments", comments.toString());
payload.add("debits", debits);
payload.add("credits", credits);
final String json = payload.toString();
final JsonCommand journalEntryCommand = JsonCommand.from(json, this.fromApiJsonHelper.parse(json), this.fromApiJsonHelper, null,
null, null, null, null, null, null, null, null, null, null, null, null, null);
this.journalEntryWritePlatformService.createJournalEntry(journalEntryCommand);
}
/**
* The GL account the withdrawal above credited, and therefore the exact account this entry has to debit
* to clear it.
* <p>
* It is the organisation-wide Liability Transfer financial activity, NOT the product's own Transfers In
* Suspense mapping: {@code CashBasedAccountingProcessorForSavings} books an account-transfer withdrawal
* as Dr Savings Control / Cr {@code FinancialActivity.LIABILITY_TRANSFER}. Debiting the product mapping
* instead leaves the credit standing in Liability Transfer for ever while the two legs of this entry
* collapse onto whichever account that mapping names.
* <p>
* Refusing outright when it is unmapped is deliberate: silently picking another account would put the
* closure's money somewhere nobody chose.
*/
private Long liabilityTransferGlAccountId() {
final List<Long> glAccountIds = this.jdbcTemplate.queryForList(
"SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type = ? ORDER BY id DESC",
Long.class, AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
if (glAccountIds.isEmpty()) {
throw new GeneralPlatformDomainRuleException("error.msg.liability.transfer.financial.activity.not.mapped",
"No Liability Transfer account is mapped under Accounting > Financial Activity Mappings, so the closure"
+ " proceeds cannot be routed to a GL account. Map it first.",
AccountingConstants.FinancialActivity.LIABILITY_TRANSFER.getValue());
}
return glAccountIds.get(0);
}
/**
* 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
@@ -829,6 +829,8 @@ public class FixedDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
@@ -840,6 +842,9 @@ public class FixedDepositAccount extends SavingsAccount {
}
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
}
public void postInterest(final MathContext mc, final LocalDate postingDate, boolean isInterestTransfer,
@@ -946,13 +951,21 @@ public class FixedDepositAccount extends SavingsAccount {
* the term restarted belongs to a term that has already been settled.
*/
private Money totalInterestPosted() {
final boolean renewedTerm = isRenewedTerm();
final LocalDate currentTermStartDate = depositStartDate();
Money interestPostedToDate = Money.zero(this.currency);
for (final SavingsAccountTransaction transaction : this.transactions) {
if (transaction.isInterestPostingAndNotReversed()
&& !DateUtils.isBefore(transaction.getTransactionDate(), currentTermStartDate)) {
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
if (!transaction.isInterestPostingAndNotReversed()) {
continue;
}
// Strictly after, and matching transactionsForCurrentTermInterestCalculation() exactly: the
// previous term's closing interest posting is dated the SAME day the new term starts, so an
// inclusive bound still counts it here while the posting-period walk excludes it -- leaving
// the old term's entire interest subtracted from a maturity amount it was never part of.
if (renewedTerm && !DateUtils.isAfter(transaction.getTransactionDate(), currentTermStartDate)) {
continue;
}
interestPostedToDate = interestPostedToDate.plus(transaction.getAmount(currency));
}
return interestPostedToDate;
@@ -759,6 +759,8 @@ public class RecurringDepositAccount extends SavingsAccount {
final boolean isPreMatureClosure, final boolean isSavingsInterestPostingAtCurrentPeriodEnd,
final Integer financialYearBeginningMonth) {
final MathContext mc = MathContext.DECIMAL64;
this.resetAccountTransactionsEndOfDayBalances(transactions, preMatureDate);
try {
final List<PostingPeriod> postingPeriods = calculateInterestPayable(mc, preMatureDate, transactions, isPreMatureClosure,
isSavingsInterestPostingAtCurrentPeriodEnd, financialYearBeginningMonth);
@@ -770,6 +772,9 @@ public class RecurringDepositAccount extends SavingsAccount {
}
this.summary.updateFromInterestPeriodSummaries(this.currency, postingPeriods);
return interestOnMaturity;
} finally {
this.resetAccountTransactionsEndOfDayBalances(transactions, DateUtils.getBusinessLocalDate());
}
}
@Override
@@ -250,7 +250,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -306,7 +310,11 @@ public class CurrentAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findByCurrentAccountIdAndStatus(currentAccountId, CurrentAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new CurrentAccountNoPendingEditException(currentAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see ShareAccountEditWritePlatformServiceJpaRepositoryImpl and
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement elsewhere.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new CurrentAccountEditSelfApprovalNotAllowedException(currentAccountId);
}
@@ -70,9 +70,17 @@ public class DepositAccountPreMatureCalculationPlatformServiceImpl implements De
final SavingsAccount account = this.depositAccountAssembler.assembleFrom(accountId, depositAccountType);
DepositAccountData accountData = null;
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(new DepositAccountOnClosureType[] { DepositAccountOnClosureType.WITHDRAW_DEPOSIT,
DepositAccountOnClosureType.TRANSFER_TO_SAVINGS });
// Premature closure proceeds must stay traceable -- into the customer's savings account, or (fixed
// deposits only) straight to a GL account the maker picks. Paying a broken deposit out over the
// counter in cash is not offered: WITHDRAW_DEPOSIT is deliberately absent here and rejected outright
// by DepositAccountTransactionDataValidator#validateClosing, so a direct API call cannot take it
// either. TRANSFER_TO_GL is offered only for fixed deposits because only
// DepositAccountDomainServiceJpa#handleFDAccountPreMatureClosure books its GL leg.
final DepositAccountOnClosureType[] closureTypes = depositAccountType.isFixedDeposit()
? new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS,
DepositAccountOnClosureType.TRANSFER_TO_GL }
: new DepositAccountOnClosureType[] { DepositAccountOnClosureType.TRANSFER_TO_SAVINGS };
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations.depositAccountOnClosureType(closureTypes);
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
.retrieveActiveForLookup(account.clientId(), DepositAccountType.SAVINGS_DEPOSIT);
@@ -543,9 +543,13 @@ public class DepositAccountReadPlatformServiceImpl implements DepositAccountRead
DepositAccountData account = this.jdbcTemplate.queryForObject(sqlBuilder.toString(), depositAccountMapper, accountId,
depositAccountType.getValue());
// TRANSFER_TO_GL is excluded for the same reason PARTIAL_REINVEST is filtered above: only one
// handler implements it (fixed-deposit PREMATURE closure, which builds its own option list in
// DepositAccountPreMatureCalculationPlatformServiceImpl). Offering it on this template -- the
// normal Close and Payment screens -- would only get it refused by validateClosing.
Collection<EnumOptionData> onAccountClosureOptions = SavingsEnumerations
.depositAccountOnClosureType(Arrays.stream(DepositAccountOnClosureType.values())
.filter(type -> includePartialReinvest || !type.isPartialReinvest())
.filter(type -> includePartialReinvest || !type.isPartialReinvest()).filter(type -> !type.isTransferToGl())
.toArray(DepositAccountOnClosureType[]::new));
final Collection<PaymentTypeData> paymentTypeOptions = this.paymentTypeReadPlatformService.retrieveAllPaymentTypes();
final Collection<SavingsAccountData> savingsAccountDatas = this.savingsAccountReadPlatformService
@@ -19,10 +19,13 @@
package org.apache.fineract.portfolio.savings.service;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingEnumerations;
@@ -202,6 +205,31 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
}
}
private static final Type OTHER_GL_MAPPINGS_TYPE = new TypeToken<List<Map<String, Object>>>() {}.getType();
private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken<Map<String, List<Map<String, Object>>>>() {}.getType();
private static List<Map<String, Object>> parseOtherGlMappings(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonArray()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_TYPE);
}
private static Map<String, List<Map<String, Object>>> parseOtherGlMappingsAsMap(final String otherGlMappingsJson) {
if (otherGlMappingsJson == null || otherGlMappingsJson.isBlank()) {
return null;
}
com.google.gson.JsonElement element = GSON.fromJson(otherGlMappingsJson, com.google.gson.JsonElement.class);
if (!element.isJsonObject()) {
return null;
}
return GSON.fromJson(element, OTHER_GL_MAPPINGS_MAP_TYPE);
}
private abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
private final String schemaSql;
@@ -221,6 +249,7 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
sqlBuilder.append("sp.lockin_period_frequency as lockinPeriodFrequency,");
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
sqlBuilder.append("sp.accounting_type as accountingType, ");
sqlBuilder.append("sp.other_gl_mappings as otherGlMappings, ");
sqlBuilder.append("sp.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
sqlBuilder.append("sp.withhold_tax as withHoldTax,");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName);
}
return DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
final DepositProductData data = DepositProductData.instance(id, name, shortName, description, currency, nominalAnnualInterestRate,
compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
taxGroupData);
final String otherGlMappingsJson = rs.getString("otherGlMappings");
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
return data;
}
}
@@ -260,7 +260,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -350,7 +354,11 @@ public class FixedDepositProductWritePlatformServiceJpaRepositoryImpl implements
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_FD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -264,7 +264,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -357,7 +361,11 @@ public class RecurringDepositProductWritePlatformServiceJpaRepositoryImpl implem
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_RD, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -203,7 +203,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -265,7 +269,11 @@ public class SavingsAccountEditWritePlatformServiceJpaRepositoryImpl implements
.findBySavingsAccountIdAndStatus(savingsId, SavingsAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsAccountNoPendingEditException(savingsId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsAccountEditSelfApprovalNotAllowedException(savingsId);
}
@@ -437,14 +437,38 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
return false;
}
protected Collection<Long> interestTransferWithdrawalTransactionIds(final SavingsAccountData savingsAccountData) {
if (savingsAccountData.getId() == null || this.savingsHelper == null || !isTransferInterestToOtherAccount(savingsAccountData)) {
return Collections.emptyList();
}
return this.savingsHelper.fetchPostInterestTransactionIds(savingsAccountData.getId());
}
private boolean isTransferInterestToOtherAccount(final SavingsAccountData savingsAccountData) {
return savingsAccountData.isTransferInterestToLinkedAccount()
&& DepositAccountType.FIXED_DEPOSIT.getValue().equals(savingsAccountData.getDepositTypeId());
}
protected List<SavingsAccountTransactionData> excludeInterestTransferWithdrawals(
final List<SavingsAccountTransactionData> transactions, final SavingsAccountData savingsAccountData) {
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
if (interestTransferWithdrawalIds.isEmpty()) {
return transactions;
}
return transactions.stream().filter(transaction -> !interestTransferWithdrawalIds.contains(transaction.getId()))
.collect(Collectors.toList());
}
private List<SavingsAccountTransactionData> retreiveOrderedNonInterestPostingTransactions(final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> listOfTransactionsSorted = retrieveListOfTransactions(savingsAccountData);
final Collection<Long> interestTransferWithdrawalIds = interestTransferWithdrawalTransactionIds(savingsAccountData);
final List<SavingsAccountTransactionData> orderedNonInterestPostingTransactions = new ArrayList<>();
for (final SavingsAccountTransactionData transaction : listOfTransactionsSorted) {
if (!(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())
&& transaction.isNotReversed() && !transaction.isReversalTransaction()) {
&& transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !interestTransferWithdrawalIds.contains(transaction.getId())) {
orderedNonInterestPostingTransactions.add(transaction);
}
}
@@ -664,10 +688,12 @@ public class SavingsAccountInterestPostingServiceImpl implements SavingsAccountI
protected void resetAccountTransactionsEndOfDayBalances(final List<SavingsAccountTransactionData> accountTransactionsSorted,
final LocalDate interestPostingUpToDate, final SavingsAccountData savingsAccountData) {
final List<SavingsAccountTransactionData> filteredTransactions = excludeInterestTransferWithdrawals(accountTransactionsSorted,
savingsAccountData);
// loop over transactions in reverse
LocalDate endOfBalanceDate = interestPostingUpToDate;
for (int i = accountTransactionsSorted.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = accountTransactionsSorted.get(i);
for (int i = filteredTransactions.size() - 1; i >= 0; i--) {
final SavingsAccountTransactionData transaction = filteredTransactions.get(i);
if (transaction.isNotReversed() && !transaction.isReversalTransaction()
&& !(transaction.isInterestPostingAndNotReversed() || transaction.isOverdraftInterestAndNotReversed())) {
transaction.updateCumulativeBalanceAndDates(MonetaryCurrency.fromCurrencyData(savingsAccountData.getCurrency()),
@@ -420,7 +420,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -524,7 +528,11 @@ public class SavingsProductWritePlatformServiceJpaRepositoryImpl implements Savi
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SAVINGS, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new SavingsProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new SavingsProductSelfApprovalNotAllowedException(productId);
}
@@ -172,7 +172,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -214,7 +219,12 @@ public class ShareAccountEditWritePlatformServiceJpaRepositoryImpl implements Sh
.findByShareAccountIdAndStatus(shareAccountId, ShareAccountPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareAccountNoPendingEditException(shareAccountId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - that permission still lets them approve someone ELSE's request,
// it just no longer lets them bypass the hold on their own (matches
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction(), the
// equivalent rule for the classic command pipeline).
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareAccountEditSelfApprovalNotAllowedException(shareAccountId);
}
@@ -882,7 +882,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -968,7 +972,11 @@ public class ShareProductWritePlatformServiceJpaRepositoryImpl implements ShareP
.findByProductTypeAndProductIdAndStatus(ProductPendingEdit.TYPE_SHARE, productId, ProductPendingEdit.STATUS_PENDING)
.orElseThrow(() -> new ShareProductNoPendingEditException(productId));
if (pendingEdit.getMaker().getId().equals(checker.getId()) && !checker.isCheckerSuperUser()) {
// Maker-identity rule: the same user can never approve their own submission, even when they
// hold CHECKER_SUPER_USER - see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction() for the
// equivalent enforcement in the classic command pipeline.
if (pendingEdit.getMaker().getId().equals(checker.getId())) {
throw new ShareProductSelfApprovalNotAllowedException(productId);
}
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:Keshav@898966}
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
@@ -415,7 +415,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:Keshav@898966}
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
@@ -163,5 +163,6 @@
<include file="parts/0588_add_premature_closure_reason.xml" relativeToChangelogFile="true"/>
<include file="parts/0589_savings_interest_posting_report.xml" relativeToChangelogFile="true"/>
<include file="parts/0590_savings_interest_posting_report_by_branch.xml" relativeToChangelogFile="true"/>
<include file="parts/0588_persist_manually_enabled_maker_checker_permissions.xml" relativeToChangelogFile="true"/>
<include file="parts/0591_cashier_allocation_time_and_deposit_counter_office.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,73 @@
<?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">
<!-- Converts manually-applied Configure Maker Checker Tasks changes into a repeatable migration.
During Admin -> Organization and Admin -> System testing, can_maker_checker was switched on
(via System -> Configure Maker Checker Tasks) for the 28 permissions below, directly in the
dev database - none of that was ever captured by a migration, so a fresh/other environment
restored from this changelog history would come up with every one of them still at its
originally-seeded false value. Every code here was independently confirmed, via read-only
inspection of both its origin changeset and the live database, to be seeded can_maker_checker
= false and currently true in the running dev DB with no existing changeset already covering
the flip (the one precedent for this pattern, DELETE_OFFICE, was flipped by changeset
0538_enable_maker_checker_for_delete_office and is deliberately left untouched here).
This changeset only ever moves can_maker_checker from false to true for these exact codes -
entity_name, action_name, grouping and every other column are left exactly as their origin
changeset defined them (including the pre-existing DELETE_ACCOUNTNUMBERFORMAT entity_name
drift, which is out of scope for this change and is not corrected here). No _CHECKER row is
touched: those already exist for every code below (added earlier by 0583/0586, or by base
Fineract for the stock codes), and can_maker_checker is not a column on a _CHECKER row.
Grouped by the screen each permission belongs to:
- Currency Configuration: UPDATE_CURRENCY
- Manage Funds: CREATE_FUND
- Working Days: UPDATE_WORKINGDAYS
- Payment Type: CREATE_PAYMENTTYPE, UPDATE_PAYMENTTYPE, DELETE_PAYMENTTYPE
- Sponsor Banks: CREATE_SPONSORBANK, UPDATE_SPONSORBANK
- Branch Profile: UPDATE_BRANCHPROFILE
- Manage Data Tables: CREATE_DATATABLE, UPDATE_DATATABLE, DELETE_DATATABLE
- Manage Codes: CREATE_CODE, UPDATE_CODE, DELETE_CODE,
CREATE_CODEVALUE, UPDATE_CODEVALUE, DELETE_CODEVALUE
- Manage Reports: CREATE_REPORT, UPDATE_REPORT, DELETE_REPORT
- Manage Hooks: CREATE_HOOK, UPDATE_HOOK, DELETE_HOOK
- Account Number Preferences: CREATE_ACCOUNTNUMBERFORMAT, DELETE_ACCOUNTNUMBERFORMAT
- Manage User Agreement: CREATE_USERAGREEMENT
- Manage Jobs (COB Workflow Step Reorder): UPDATE_BATCH_BUSINESS_STEP
Deliberately NOT included (confirmed still false, or out of scope, and left untouched):
- UPDATE_PERMISSION, UPDATE_CONFIGURATION (the Configure Maker Checker Tasks self-lockout
exclusion added by PermissionReadPlatformServiceImpl stays in effect; these remain false)
- UPDATE_SPONSORDDGLMAPPING, all Entity-to-Entity Mapping, External Services and Manage
Survey permissions (confirmed still false - never actually toggled on)
- CREATE_ROLE/UPDATE_ROLE/DELETE_ROLE/etc., CREATE_GLACCOUNT, CREATE_CHARGE,
CREATE_LOANPRODUCT, CREATE_SAVINGSPRODUCT and other permissions observed to already be
true in the live DB but belonging to Accounting/Products/Roles modules, not Admin ->
Organization or Admin -> System - out of scope for this change and left as pre-existing
drift for a separate decision. -->
<changeSet id="0588_persist_manually_enabled_maker_checker_permissions" author="cbs-anshul">
<update tableName="m_permission">
<column name="can_maker_checker" valueBoolean="true"/>
<where>code IN (
'UPDATE_CURRENCY',
'CREATE_FUND',
'UPDATE_WORKINGDAYS',
'CREATE_PAYMENTTYPE', 'UPDATE_PAYMENTTYPE', 'DELETE_PAYMENTTYPE',
'CREATE_SPONSORBANK', 'UPDATE_SPONSORBANK',
'UPDATE_BRANCHPROFILE',
'CREATE_DATATABLE', 'UPDATE_DATATABLE', 'DELETE_DATATABLE',
'CREATE_CODE', 'UPDATE_CODE', 'DELETE_CODE',
'CREATE_CODEVALUE', 'UPDATE_CODEVALUE', 'DELETE_CODEVALUE',
'CREATE_REPORT', 'UPDATE_REPORT', 'DELETE_REPORT',
'CREATE_HOOK', 'UPDATE_HOOK', 'DELETE_HOOK',
'CREATE_ACCOUNTNUMBERFORMAT', 'DELETE_ACCOUNTNUMBERFORMAT',
'CREATE_USERAGREEMENT',
'UPDATE_BATCH_BUSINESS_STEP'
)</where>
</update>
</changeSet>
</databaseChangeLog>
@@ -116,7 +116,11 @@ public class CommandSourceServiceTest {
}
@Test
public void testProcessCommand_NonGLAccount_SuperUser_BypassesMakerChecker() {
public void testProcessCommand_NonGLAccount_SuperUser_OwnSubmission_DoesNotBypassMakerChecker() {
// A maker who happens to hold CHECKER_SUPER_USER must not auto-approve their own submission -
// CHECKER_SUPER_USER only ever lets its holder approve a DIFFERENT user's request (see
// PortfolioCommandSourceWritePlatformServiceImpl.validateMakerCheckerTransaction()). This
// replaces the old bypass this test used to assert.
NewCommandSourceHandler handler = Mockito.mock(NewCommandSourceHandler.class);
JsonCommand jsonCommand = Mockito.mock(JsonCommand.class);
CommandSource commandSource = Mockito.mock(CommandSource.class);
@@ -125,6 +129,8 @@ public class CommandSourceServiceTest {
Mockito.when(commandSource.getPermissionCode()).thenReturn("WITHDRAW_SAVINGSACCOUNT");
Mockito.when(commandSource.getEntityName()).thenReturn("SAVINGSACCOUNT");
Mockito.when(commandSource.getActionName()).thenReturn("WITHDRAW");
Mockito.when(commandSource.getId()).thenReturn(300L);
Mockito.when(commandSource.getResourceId()).thenReturn(400L);
Mockito.when(configurationDomainService.isMakerCheckerEnabledForTask("WITHDRAW_SAVINGSACCOUNT")).thenReturn(true);
Mockito.when(superUser.isCheckerSuperUser()).thenReturn(true);
@@ -132,12 +138,12 @@ public class CommandSourceServiceTest {
.withCommandId(1L).build();
Mockito.when(handler.processCommand(jsonCommand)).thenReturn(dummyResult);
org.apache.fineract.infrastructure.core.data.CommandProcessingResult result = underTest.processCommand(handler, jsonCommand,
commandSource, superUser, false);
Assertions.assertThrows(org.apache.fineract.commands.exception.RollbackTransactionNotApprovedException.class, () -> {
underTest.processCommand(handler, jsonCommand, commandSource, superUser, false);
});
Assertions.assertNotNull(result);
Mockito.verify(commandSource).markAsChecked(superUser);
Mockito.verify(commandSource, Mockito.never()).markAsAwaitingApproval();
Mockito.verify(commandSource).markAsAwaitingApproval();
Mockito.verify(commandSource, Mockito.never()).markAsChecked(any());
}
@Test
@@ -58,6 +58,9 @@ public class DepositProductData {
// accounting
protected final EnumOptionData accountingRule;
private List<Map<String, Object>> otherGlMappings;
private Map<String, List<Map<String, Object>>> otherGlMappingsMap;
protected final Map<String, Object> accountingMappings;
protected final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings;
protected final Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings;
@@ -453,4 +456,20 @@ public class DepositProductData {
public EnumOptionData getInterestCompoundingPeriodType() {
return interestCompoundingPeriodType;
}
public List<Map<String, Object>> getOtherGlMappings() {
return this.otherGlMappings;
}
public void setOtherGlMappings(List<Map<String, Object>> otherGlMappings) {
this.otherGlMappings = otherGlMappings;
}
public Map<String, List<Map<String, Object>>> getOtherGlMappingsMap() {
return this.otherGlMappingsMap;
}
public void setOtherGlMappingsMap(Map<String, List<Map<String, Object>>> otherGlMappingsMap) {
this.otherGlMappingsMap = otherGlMappingsMap;
}
}
@@ -244,7 +244,7 @@ public final class FixedDepositProductData extends DepositProductData {
final Collection<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<TaxGroupData> taxGroupOptions = null;
return new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
final FixedDepositProductData result = new FixedDepositProductData(depositProductData.id, depositProductData.name, depositProductData.shortName,
depositProductData.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate,
depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType,
depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType,
@@ -258,6 +258,9 @@ public final class FixedDepositProductData extends DepositProductData {
minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm,
inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions,
depositProductData.withHoldTax, depositProductData.taxGroup, taxGroupOptions);
result.setOtherGlMappings(depositProductData.getOtherGlMappings());
result.setOtherGlMappingsMap(depositProductData.getOtherGlMappingsMap());
return result;
}
public static FixedDepositProductData lookup(final Long id, final String name) {
@@ -609,6 +612,8 @@ public final class FixedDepositProductData extends DepositProductData {
this.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays;
this.setOtherGlMappings(src.getOtherGlMappings());
this.setOtherGlMappingsMap(src.getOtherGlMappingsMap());
}
private Boolean withdrawalFeeForTransfers;
@@ -52,6 +52,7 @@ import static org.apache.fineract.portfolio.savings.SavingsApiConstants.lockinPe
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.minBalanceForInterestCalculationParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nominalAnnualInterestRateParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.otherGlMappingsParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -247,6 +248,10 @@ public class DepositProductAssembler {
fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
}
if (command.hasParameter(otherGlMappingsParamName)) {
fixedDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
fixedDepositProduct.validateDomainRules();
return fixedDepositProduct;
@@ -402,6 +407,10 @@ public class DepositProductAssembler {
recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
}
if (command.hasParameter(otherGlMappingsParamName)) {
recurringDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
recurringDepositProduct.validateDomainRules();
return recurringDepositProduct;
@@ -1123,6 +1123,10 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
return this.otherGlMappings;
}
public void setOtherGlMappings(final String otherGlMappings) {
this.otherGlMappings = otherGlMappings;
}
// Enhanced Savings Product Getters and Setters
public String getAccountType() {
return accountType;
@@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
@@ -96,6 +97,63 @@ public class SavingsSchedularInterestPoster {
}
}
private final Map<String, Long> namedGlAccountCache = new HashMap<>();
private Long findNamedGlAccount(final Long savingsProductId, final String... accountNames) {
if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
return null;
}
for (final String accountName : accountNames) {
if (accountName == null) {
continue;
}
final String cacheKey = savingsProductId + ":" + accountName.trim().toLowerCase();
if (this.namedGlAccountCache.containsKey(cacheKey)) {
final Long cached = this.namedGlAccountCache.get(cacheKey);
if (cached != null) {
return cached;
}
continue;
}
final List<Long> found = this.jdbcTemplate.queryForList("""
SELECT gl.id
FROM (SELECT other_gl_mappings::jsonb AS doc
FROM m_savings_product
WHERE id = ?
AND other_gl_mappings IS NOT NULL
AND other_gl_mappings <> ''
AND other_gl_mappings LIKE '{%') sp
CROSS JOIN LATERAL jsonb_array_elements(
COALESCE(sp.doc -> 'assets', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb)
) AS m(entry)
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
LIMIT 1
""", Long.class, savingsProductId, accountName);
if (!found.isEmpty()) {
this.namedGlAccountCache.put(cacheKey, found.get(0));
return found.get(0);
} else {
this.namedGlAccountCache.put(cacheKey, null);
}
}
return null;
}
private void addJournalEntryLeg(final List<Object[]> params, final Long accountId, final Long officeId,
final String currencyCode, final String transactionId, final Long savingsTransactionId,
final LocalDate transactionDate, final JournalEntryType entryType, final BigDecimal amount,
final Long savingsAccountId, final OffsetDateTime auditDatetime, final Long userId,
final LocalDate businessDate) {
params.add(new Object[] { accountId, officeId, null, currencyCode, transactionId,
savingsTransactionId, null, false, null, false, transactionDate, entryType.getValue().longValue(),
amount, null, entryType.getValue().longValue(), savingsAccountId, auditDatetime, auditDatetime, false,
BigDecimal.ZERO, BigDecimal.ZERO, null, transactionDate, null, userId, userId, businessDate });
}
private void batchUpdateJournalEntries(final List<SavingsAccountData> savingsAccountDataList,
final HashMap<String, SavingsAccountTransactionData> savingsAccountTransactionDataHashMap)
throws DataAccessException, NullPointerException {
@@ -114,26 +172,86 @@ public class SavingsSchedularInterestPoster {
savingsAccountTransactionData.setId(dataFromFetch.getId());
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
&& savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) {
OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime();
paramsForGLInsertion.add(
new Object[] { savingsAccountTransactionData.getAccountCredit(), savingsAccountData.getOfficeId(), null,
currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(),
savingsAccountTransactionData.getId(), null, false, null, false,
savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(),
savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(),
savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO,
null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId,
DateUtils.getBusinessLocalDate() });
final OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime();
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
final String txnIdentifier = SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString();
final Long savingsTxnId = savingsAccountTransactionData.getId();
final LocalDate txnDate = savingsAccountTransactionData.getTransactionDate();
final BigDecimal amount = savingsAccountTransactionData.getAmount();
final Long savingsAccountId = savingsAccountData.getId();
final Long officeId = savingsAccountData.getOfficeId();
paramsForGLInsertion
.add(new Object[] { savingsAccountTransactionData.getAccountDebit(), savingsAccountData.getOfficeId(), null,
currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(),
savingsAccountTransactionData.getId(), null, false, null, false,
savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(),
savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(),
savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO,
null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId,
DateUtils.getBusinessLocalDate() });
if (Boolean.TRUE.equals(isOverdraft)) {
addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId,
currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount,
savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId,
currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount,
savingsAccountId, auditDatetime, userId, businessDate);
} else {
final Long expenseAccountId = savingsAccountData.getGlAccountIdForInterestOnSavings();
final Long controlAccountId = savingsAccountData.getGlAccountIdForSavingsControl();
Long payableAccountId = savingsAccountData.getGlAccountIdForInterestPayable();
final Long productId = savingsAccountData.getSavingsProductId() != null
? savingsAccountData.getSavingsProductId()
: savingsAccountData.getProductId();
if (payableAccountId == null || payableAccountId == 0) {
payableAccountId = findNamedGlAccount(productId,
"Fixed Deposit Interest Payable", "Savings Interest Payable", "Recurring Deposit Interest Payable",
"Interest Payable", "Interest Payable Account");
}
final Long paidAccountId = findNamedGlAccount(productId,
"Fixed Deposit Interest Paid", "Savings Interest Paid", "Recurring Deposit Interest Paid",
"Interest Paid", "Interest Paid Account");
final boolean hasPayable = payableAccountId != null && payableAccountId != 0 && !payableAccountId.equals(controlAccountId);
final boolean hasPaid = paidAccountId != null && paidAccountId != 0 && !paidAccountId.equals(payableAccountId);
log.info("SavingsSchedularInterestPoster: accountId={}, productId={}, expense={}, payable={}, paid={}, control={}, amount={}",
savingsAccountId, productId, expenseAccountId, payableAccountId, paidAccountId, controlAccountId, amount);
if (hasPayable && hasPaid) {
// Stage 1: Dr Interest Expense / Cr Interest Payable
addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
// Stage 2: Dr Interest Payable / Cr Interest Paid
addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
// Stage 3: Dr Interest Paid / Cr Savings Control
addJournalEntryLeg(paramsForGLInsertion, paidAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
} else if (hasPayable) {
// Stage 1: Dr Interest Expense / Cr Interest Payable
addJournalEntryLeg(paramsForGLInsertion, expenseAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
// Stage 2: Dr Interest Payable / Cr Savings Control
addJournalEntryLeg(paramsForGLInsertion, payableAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.DEBIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, controlAccountId, officeId, currencyCode, txnIdentifier,
savingsTxnId, txnDate, JournalEntryType.CREDIT, amount, savingsAccountId, auditDatetime, userId, businessDate);
} else {
// Standard 2-leg entry: Dr Interest Expense / Cr Savings Control
addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId,
currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount,
savingsAccountId, auditDatetime, userId, businessDate);
addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId,
currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount,
savingsAccountId, auditDatetime, userId, businessDate);
}
}
}
}