diff --git a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java index fd2b66e0d..aa88c1989 100644 --- a/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/portfolio/savings/DepositsApiConstants.java @@ -252,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 PRECLOSURE_REQUEST_DATA_PARAMETERS = new HashSet<>( Arrays.asList(preClosurePenalApplicableParamName, preClosurePenalInterestParamName, preClosurePenalInterestOnTypeIdParamName)); @@ -390,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; } @@ -501,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; } diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java index 1ee9fe013..062bb76a2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/AccountingProcessorHelper.java @@ -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,34 +809,37 @@ 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. - final List found = 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 m.entry ->> 'accountName' = ? - LIMIT 1 - """, Long.class, savingsProductId, accountName); - return found.isEmpty() ? null : glAccountRepository.findById(found.get(0)).orElse(null); + for (final String accountName : accountNames) { + if (accountName == null) { + continue; + } + final List found = 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()) { + return glAccountRepository.findById(found.get(0)).orElse(null); + } + } + return null; } /** diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java index 24f1b6aaf..5bb9f4f2c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/savings/service/DepositProductReadPlatformServiceImpl.java @@ -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>>() {}.getType(); + private static final Type OTHER_GL_MAPPINGS_MAP_TYPE = new TypeToken>>>() {}.getType(); + + private static List> 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>> 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 { 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; } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java index 04c72bf02..85e518bc9 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/DepositProductData.java @@ -58,6 +58,9 @@ public class DepositProductData { // accounting protected final EnumOptionData accountingRule; + + private List> otherGlMappings; + private Map>> otherGlMappingsMap; protected final Map accountingMappings; protected final Collection paymentChannelToFundSourceMappings; protected final Collection feeToIncomeAccountMappings; @@ -453,4 +456,20 @@ public class DepositProductData { public EnumOptionData getInterestCompoundingPeriodType() { return interestCompoundingPeriodType; } + + public List> getOtherGlMappings() { + return this.otherGlMappings; + } + + public void setOtherGlMappings(List> otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + + public Map>> getOtherGlMappingsMap() { + return this.otherGlMappingsMap; + } + + public void setOtherGlMappingsMap(Map>> otherGlMappingsMap) { + this.otherGlMappingsMap = otherGlMappingsMap; + } } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java index 88004012f..1909f9190 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/FixedDepositProductData.java @@ -244,7 +244,7 @@ public final class FixedDepositProductData extends DepositProductData { final Collection periodFrequencyTypeOptions = null; final Collection 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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java index 64402d2e7..d65793dc8 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/DepositProductAssembler.java @@ -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; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java index 1f21f0a30..d47b85254 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/domain/SavingsProduct.java @@ -1123,6 +1123,10 @@ public class SavingsProduct extends AbstractPersistableCustom { return this.otherGlMappings; } + public void setOtherGlMappings(final String otherGlMappings) { + this.otherGlMappings = otherGlMappings; + } + // Enhanced Savings Product Getters and Setters public String getAccountType() { return accountType; diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java index 6928f730b..0db77af5e 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/service/SavingsSchedularInterestPoster.java @@ -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 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 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 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 savingsAccountDataList, final HashMap 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); + } + } } }