From 331c55316439a3e907eb74ed36e9714e8c2a2def Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Fri, 25 Sep 2026 12:40:46 +0530 Subject: [PATCH] fix(accounting): interest postings were never reaching the general ledger createInterestPostingJournalEntriesForSavings() documents a 3-stage posting (Dr Interest On Savings -> Cr Payable, Dr Payable -> Cr Paid, Dr Paid -> Cr Savings Control), but the first stage was never actually coded -- the interest expense account was never even fetched. For a product with no distinct Interest Payable/Paid accounts configured (every product in this system, checked directly), the remaining two stages then collapsed into a same-account no-op that correctly refuses to post, leaving interest silently added to the account's balance with zero GL entries. Affects every deposit type equally (Savings, FD, RD), not just FD -- confirmed by checking every product's accounting mappings. Added the missing stage. Co-Authored-By: Claude Sonnet 5 --- .../service/AccountingProcessorHelper.java | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) 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 f8235a2a9..b5c375a61 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 @@ -651,6 +651,8 @@ public class AccountingProcessorHelper { final GLAccount controlAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, CashAccountsForSavings.SAVINGS_CONTROL.getValue(), paymentTypeId); + final GLAccount interestOnSavingsAccount = getLinkedGLAccountForSavingsProduct(savingsProductId, + CashAccountsForSavings.INTEREST_ON_SAVINGS.getValue(), paymentTypeId); // The payable comes from the Interest Payable row on the product's Accounting tab, which is // where a bank actually picks it. The reserved name in the product's other GL mappings is @@ -664,13 +666,20 @@ public class AccountingProcessorHelper { final GLAccount paidAccount = Optional.ofNullable(namedSavingsGlAccount(savingsProductId, SAVINGS_INTEREST_PAID_MAPPING)) .orElse(payableAccount); - // The interest expense is not raised here. It is recognised as the interest accrues, day by - // day, and this posting settles that recognised debt - raising the expense again at payout - // would put the same interest on the P&L twice. This mirrors the pooled posting service, - // which pays a product's whole membership the same way; a closure reaching this path must - // produce the same entries as the nightly run, or the two disagree on what one payment did. + // Stage 1: the expense is recognised against whatever the liability sits in right now -- the + // named Payable account if the product has one, or straight against Savings Control if it + // doesn't. This was missing entirely: every product, configured or not, posted zero journal + // entries for interest until this leg existed, since a product with no Payable/Paid names + // configured also collapsed stages 2 and 3 below into a same-account no-op with nothing else + // to post in their place. + postSavingsLeg(office, currencyCode, interestOnSavingsAccount, payableAccount, savingsId, transactionId, transactionDate, amount, + isReversal); + + // The interest expense is not raised again here. It was just raised above, and this posting + // only settles that recognised debt onward to where it is actually paid out from -- raising + // the expense a second time would put the same interest on the P&L twice. // - // Stage 1: the liability moves from the payable to the account it is paid out of. Where the + // Stage 2: the liability moves from the payable to the account it is paid out of. Where the // product names no paid account both resolve to the payable and this says nothing, so it is // skipped and the payout comes straight out of the payable. final boolean payableIsPaid = payableAccount.getId().equals(paidAccount.getId()); @@ -679,7 +688,7 @@ public class AccountingProcessorHelper { isReversal); } - // Stage 2: into the member's own balance. + // Stage 3: into the member's own balance. final GLAccount payoutFromAccount = payableIsPaid ? payableAccount : paidAccount; postSavingsLeg(office, currencyCode, payoutFromAccount, controlAccount, savingsId, transactionId, transactionDate, amount, isReversal);