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
@@ -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);
}
}
}
}