fix(accounting,branchday): 3-stage FD interest posting in scheduler, fix branch day close business date validation
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s

This commit is contained in:
2026-10-04 16:02:25 +05:30
parent aa264097e7
commit fd827dec27
8 changed files with 275 additions and 63 deletions
@@ -252,7 +252,7 @@ public final class DepositsApiConstants {
SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(), SavingProductAccountingParams.PENALTY_INCOME_ACCOUNT_MAPPING.getValue(),
SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(), SavingProductAccountingParams.INTEREST_PAYABLE.getValue(), SavingProductAccountingParams.PENALTIES_RECEIVABLE.getValue(),
SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName, SavingProductAccountingParams.FEES_RECEIVABLE.getValue(), chartsParamName, SavingsApiConstants.withHoldTaxParamName,
SavingsApiConstants.taxGroupIdParamName)); SavingsApiConstants.taxGroupIdParamName, SavingsApiConstants.otherGlMappingsParamName));
private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>( private static final Set<String> PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>(
Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName)); Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName));
@@ -390,6 +390,8 @@ public final class DepositsApiConstants {
fixedDepositRequestData.add("paymentChannelToFundSourceMappings"); fixedDepositRequestData.add("paymentChannelToFundSourceMappings");
fixedDepositRequestData.add("feeToIncomeAccountMappings"); fixedDepositRequestData.add("feeToIncomeAccountMappings");
fixedDepositRequestData.add("penaltyToIncomeAccountMappings"); fixedDepositRequestData.add("penaltyToIncomeAccountMappings");
fixedDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
fixedDepositRequestData.add("otherGlMappingsMap");
return fixedDepositRequestData; return fixedDepositRequestData;
} }
@@ -501,6 +503,8 @@ public final class DepositsApiConstants {
recurringDepositRequestData.add("paymentChannelToFundSourceMappings"); recurringDepositRequestData.add("paymentChannelToFundSourceMappings");
recurringDepositRequestData.add("feeToIncomeAccountMappings"); recurringDepositRequestData.add("feeToIncomeAccountMappings");
recurringDepositRequestData.add("penaltyToIncomeAccountMappings"); recurringDepositRequestData.add("penaltyToIncomeAccountMappings");
recurringDepositRequestData.add(SavingsApiConstants.otherGlMappingsParamName);
recurringDepositRequestData.add("otherGlMappingsMap");
return recurringDepositRequestData; return recurringDepositRequestData;
} }
@@ -27,6 +27,7 @@ import java.util.Map;
import java.util.Objects; import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
import org.apache.fineract.accounting.closure.domain.GLClosure; import org.apache.fineract.accounting.closure.domain.GLClosure;
import org.apache.fineract.accounting.closure.domain.GLClosureRepository; 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.dao.DataAccessException;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
@Slf4j
@RequiredArgsConstructor @RequiredArgsConstructor
public class AccountingProcessorHelper { public class AccountingProcessorHelper {
@@ -662,16 +664,25 @@ public class AccountingProcessorHelper {
final GLAccount payableAccount = Optional final GLAccount payableAccount = Optional
.ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId, .ofNullable(optionalLinkedGLAccountForSavingsProduct(savingsProductId,
AccrualAccountsForSavings.INTEREST_PAYABLE.getValue(), paymentTypeId)) 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); .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); .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 // 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 // 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 // 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. // a product has no liability to draw down, so it keeps the single pair it always posted.
if (payableAccount.getId().equals(controlAccount.getId())) { 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, postSavingsLeg(office, currencyCode, interestOnSavingsAccount, controlAccount, savingsId, transactionId, transactionDate,
amount, isReversal); amount, isReversal);
return; return;
@@ -697,28 +708,34 @@ public class AccountingProcessorHelper {
final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType()); final boolean payableIsLiability = GLAccountType.LIABILITY.getValue().equals(payableAccount.getType());
final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode); final BigDecimal alreadyAccrued = payableBalance(office, payableAccount, currencyCode);
final BigDecimal shortfall = amount.subtract(alreadyAccrued).max(BigDecimal.ZERO); 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) { 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, postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate,
shortfall, false); 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()); final boolean hasSeparatePaidAccount = !payableAccount.getId().equals(paidAccount.getId());
if (hasSeparatePaidAccount) { 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); isReversal);
} }
// Into the member's own balance.
final GLAccount payoutFromAccount = hasSeparatePaidAccount ? paidAccount : interestOnSavingsAccount;
postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount,
isReversal);
} }
/** /**
@@ -792,34 +809,37 @@ public class AccountingProcessorHelper {
return balance == null ? BigDecimal.ZERO : balance; return balance == null ? BigDecimal.ZERO : balance;
} }
private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String accountName) { private GLAccount namedSavingsGlAccount(final Long savingsProductId, final String... accountNames) {
if (savingsProductId == null) { if (savingsProductId == null || accountNames == null || accountNames.length == 0) {
return null; return null;
} }
// The column holds an empty string on a product saved with no named mappings, and casting for (final String accountName : accountNames) {
// that to jsonb is an error rather than an empty document - so the cast is done inside a if (accountName == null) {
// subquery that has already filtered those rows out. Postgres is free to evaluate a WHERE continue;
// clause in any order, so guarding at the same query level as the cast would only work by }
// luck. final List<Long> found = jdbcTemplate.queryForList("""
final List<Long> found = jdbcTemplate.queryForList(""" SELECT gl.id
SELECT gl.id FROM (SELECT other_gl_mappings::jsonb AS doc
FROM (SELECT other_gl_mappings::jsonb AS doc FROM m_savings_product
FROM m_savings_product WHERE id = ?
WHERE id = ? AND other_gl_mappings IS NOT NULL
AND other_gl_mappings IS NOT NULL AND other_gl_mappings <> ''
AND other_gl_mappings <> '' AND other_gl_mappings LIKE '{%') sp
AND other_gl_mappings LIKE '{%') sp CROSS JOIN LATERAL jsonb_array_elements(
CROSS JOIN LATERAL jsonb_array_elements( COALESCE(sp.doc -> 'assets', '[]'::jsonb)
COALESCE(sp.doc -> 'assets', '[]'::jsonb) || COALESCE(sp.doc -> 'liabilities', '[]'::jsonb)
|| COALESCE(sp.doc -> 'liabilities', '[]'::jsonb) || COALESCE(sp.doc -> 'expenses', '[]'::jsonb)
|| COALESCE(sp.doc -> 'expenses', '[]'::jsonb) || COALESCE(sp.doc -> 'income', '[]'::jsonb)
|| COALESCE(sp.doc -> 'income', '[]'::jsonb) ) AS m(entry)
) AS m(entry) JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode'
JOIN acc_gl_account gl ON gl.gl_code = m.entry ->> 'glCode' WHERE lower(trim(m.entry ->> 'accountName')) = lower(trim(?))
WHERE m.entry ->> 'accountName' = ? LIMIT 1
LIMIT 1 """, Long.class, savingsProductId, accountName);
""", Long.class, savingsProductId, accountName); if (!found.isEmpty()) {
return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); return glAccountRepository.findById(found.get(0)).orElse(null);
}
}
return null;
} }
/** /**
@@ -19,10 +19,13 @@
package org.apache.fineract.portfolio.savings.service; package org.apache.fineract.portfolio.savings.service;
import com.google.gson.Gson; import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
import java.lang.reflect.Type;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Collection; import java.util.Collection;
import java.util.List;
import java.util.Map; import java.util.Map;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.apache.fineract.accounting.common.AccountingEnumerations; 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 abstract static class DepositProductMapper implements RowMapper<DepositProductData> {
private final String schemaSql; 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 as lockinPeriodFrequency,");
sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, "); sqlBuilder.append("sp.lockin_period_frequency_enum as lockinPeriodFrequencyType, ");
sqlBuilder.append("sp.accounting_type as accountingType, "); 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.min_balance_for_interest_calculation as minBalanceForInterestCalculation, ");
sqlBuilder.append("sp.withhold_tax as withHoldTax,"); sqlBuilder.append("sp.withhold_tax as withHoldTax,");
sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName "); sqlBuilder.append("tg.id as taxGroupId, tg.name as taxGroupName ");
@@ -284,10 +313,14 @@ public class DepositProductReadPlatformServiceImpl implements DepositProductRead
taxGroupData = TaxGroupData.lookup(taxGroupId, taxGroupName); 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, compoundingInterestPeriodType, interestPostingPeriodType, interestCalculationType, interestCalculationDaysInYearType,
lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax, lockinPeriodFrequency, lockinPeriodFrequencyType, accountingRuleType, minBalanceForInterestCalculation, withHoldTax,
taxGroupData); taxGroupData);
final String otherGlMappingsJson = rs.getString("otherGlMappings");
data.setOtherGlMappings(parseOtherGlMappings(otherGlMappingsJson));
data.setOtherGlMappingsMap(parseOtherGlMappingsAsMap(otherGlMappingsJson));
return data;
} }
} }
@@ -58,6 +58,9 @@ public class DepositProductData {
// accounting // accounting
protected final EnumOptionData accountingRule; 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 Map<String, Object> accountingMappings;
protected final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings; protected final Collection<PaymentTypeToGLAccountMapper> paymentChannelToFundSourceMappings;
protected final Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings; protected final Collection<ChargeToGLAccountMapper> feeToIncomeAccountMappings;
@@ -453,4 +456,20 @@ public class DepositProductData {
public EnumOptionData getInterestCompoundingPeriodType() { public EnumOptionData getInterestCompoundingPeriodType() {
return interestCompoundingPeriodType; 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<EnumOptionData> periodFrequencyTypeOptions = null;
final Collection<TaxGroupData> taxGroupOptions = 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.description, depositProductData.currency, depositProductData.nominalAnnualInterestRate,
depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType, depositProductData.interestCompoundingPeriodType, depositProductData.interestPostingPeriodType,
depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType, depositProductData.interestCalculationType, depositProductData.interestCalculationDaysInYearType,
@@ -258,6 +258,9 @@ public final class FixedDepositProductData extends DepositProductData {
minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm, minDepositTerm, maxDepositTerm, minDepositTermType, maxDepositTermType, inMultiplesOfDepositTerm,
inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions, inMultiplesOfDepositTermType, minDepositAmount, depositAmount, maxDepositAmount, periodFrequencyTypeOptions,
depositProductData.withHoldTax, depositProductData.taxGroup, taxGroupOptions); 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) { 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.balanceFrequency = src.balanceFrequency;
this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers; this.withdrawalFeeForTransfers = src.withdrawalFeeForTransfers;
this.renewalGracePeriodDays = src.renewalGracePeriodDays; this.renewalGracePeriodDays = src.renewalGracePeriodDays;
this.setOtherGlMappings(src.getOtherGlMappings());
this.setOtherGlMappingsMap(src.getOtherGlMappingsMap());
} }
private Boolean withdrawalFeeForTransfers; 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.minBalanceForInterestCalculationParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.nameParamName; 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.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.shortNameParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.taxGroupIdParamName;
import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName; import static org.apache.fineract.portfolio.savings.SavingsApiConstants.withHoldTaxParamName;
@@ -247,6 +248,10 @@ public class DepositProductAssembler {
fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); fixedDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
} }
if (command.hasParameter(otherGlMappingsParamName)) {
fixedDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
fixedDepositProduct.validateDomainRules(); fixedDepositProduct.validateDomainRules();
return fixedDepositProduct; return fixedDepositProduct;
@@ -402,6 +407,10 @@ public class DepositProductAssembler {
recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers")); recurringDepositProduct.setWithdrawalFeeApplicableForTransfer(command.booleanPrimitiveValueOfParameterNamed("withdrawalFeeForTransfers"));
} }
if (command.hasParameter(otherGlMappingsParamName)) {
recurringDepositProduct.setOtherGlMappings(command.jsonFragment(otherGlMappingsParamName));
}
recurringDepositProduct.validateDomainRules(); recurringDepositProduct.validateDomainRules();
return recurringDepositProduct; return recurringDepositProduct;
@@ -1123,6 +1123,10 @@ public class SavingsProduct extends AbstractPersistableCustom<Long> {
return this.otherGlMappings; return this.otherGlMappings;
} }
public void setOtherGlMappings(final String otherGlMappings) {
this.otherGlMappings = otherGlMappings;
}
// Enhanced Savings Product Getters and Setters // Enhanced Savings Product Getters and Setters
public String getAccountType() { public String getAccountType() {
return accountType; return accountType;
@@ -30,6 +30,7 @@ import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.UUID; import java.util.UUID;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.Setter; 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, private void batchUpdateJournalEntries(final List<SavingsAccountData> savingsAccountDataList,
final HashMap<String, SavingsAccountTransactionData> savingsAccountTransactionDataHashMap) final HashMap<String, SavingsAccountTransactionData> savingsAccountTransactionDataHashMap)
throws DataAccessException, NullPointerException { throws DataAccessException, NullPointerException {
@@ -114,26 +172,86 @@ public class SavingsSchedularInterestPoster {
savingsAccountTransactionData.setId(dataFromFetch.getId()); savingsAccountTransactionData.setId(dataFromFetch.getId());
if (savingsAccountData.getGlAccountIdForSavingsControl() != 0 if (savingsAccountData.getGlAccountIdForSavingsControl() != 0
&& savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) { && savingsAccountData.getGlAccountIdForInterestOnSavings() != 0) {
OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime(); final OffsetDateTime auditDatetime = DateUtils.getAuditOffsetDateTime();
paramsForGLInsertion.add( final LocalDate businessDate = DateUtils.getBusinessLocalDate();
new Object[] { savingsAccountTransactionData.getAccountCredit(), savingsAccountData.getOfficeId(), null, final String txnIdentifier = SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString();
currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), final Long savingsTxnId = savingsAccountTransactionData.getId();
savingsAccountTransactionData.getId(), null, false, null, false, final LocalDate txnDate = savingsAccountTransactionData.getTransactionDate();
savingsAccountTransactionData.getTransactionDate(), JournalEntryType.CREDIT.getValue().longValue(), final BigDecimal amount = savingsAccountTransactionData.getAmount();
savingsAccountTransactionData.getAmount(), null, JournalEntryType.CREDIT.getValue().longValue(), final Long savingsAccountId = savingsAccountData.getId();
savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, final Long officeId = savingsAccountData.getOfficeId();
null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId,
DateUtils.getBusinessLocalDate() });
paramsForGLInsertion if (Boolean.TRUE.equals(isOverdraft)) {
.add(new Object[] { savingsAccountTransactionData.getAccountDebit(), savingsAccountData.getOfficeId(), null, addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountCredit(), officeId,
currencyCode, SAVINGS_TRANSACTION_IDENTIFIER + savingsAccountTransactionData.getId().toString(), currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.CREDIT, amount,
savingsAccountTransactionData.getId(), null, false, null, false, savingsAccountId, auditDatetime, userId, businessDate);
savingsAccountTransactionData.getTransactionDate(), JournalEntryType.DEBIT.getValue().longValue(), addJournalEntryLeg(paramsForGLInsertion, savingsAccountTransactionData.getAccountDebit(), officeId,
savingsAccountTransactionData.getAmount(), null, JournalEntryType.DEBIT.getValue().longValue(), currencyCode, txnIdentifier, savingsTxnId, txnDate, JournalEntryType.DEBIT, amount,
savingsAccountData.getId(), auditDatetime, auditDatetime, false, BigDecimal.ZERO, BigDecimal.ZERO, savingsAccountId, auditDatetime, userId, businessDate);
null, savingsAccountTransactionData.getTransactionDate(), null, userId, userId, } else {
DateUtils.getBusinessLocalDate() }); 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);
}
}
} }
} }