From 3d196276c8716d1e79ebff2bc5600ed2338d0576 Mon Sep 17 00:00:00 2001 From: keshav Date: Fri, 11 Sep 2026 10:48:50 +0530 Subject: [PATCH] feat(savings): support rounding adjustment GL mapping and floor rounding on clerk closure --- ...AccountMappingReadPlatformServiceImpl.java | 4 + ...avingsProductToGLAccountMappingHelper.java | 18 +++++ .../common/AccountingConstants.java | 20 ++++- ...ccountMappingWritePlatformServiceImpl.java | 9 +++ .../api/ClerkClosureRequestApiResource.java | 76 +++++++++++++++---- .../data/SavingsProductDataValidator.java | 10 +++ 6 files changed, 117 insertions(+), 20 deletions(-) diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java index 859a64399..8c6ed8881 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/ProductToGLAccountMappingReadPlatformServiceImpl.java @@ -426,6 +426,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); } else if (glAccountForSavings.equals(AccrualAccountsForSavings.ESCHEAT_LIABILITY)) { accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); + } else if (glAccountForSavings.equals(AccrualAccountsForSavings.ROUNDING_ADJUSTMENT)) { + accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData); // Expense } else if (glAccountForSavings.equals(AccrualAccountsForSavings.INTEREST_ON_SAVINGS)) { accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); @@ -470,6 +472,8 @@ public class ProductToGLAccountMappingReadPlatformServiceImpl implements Product accountMappingDetails.put(SavingProductAccountingDataParams.INCOME_FROM_INTEREST.getValue(), glAccountData); } else if (glAccountForSavings.equals(CashAccountsForSavings.ESCHEAT_LIABILITY)) { accountMappingDetails.put(SavingProductAccountingDataParams.ESCHEAT_LIABILITY.getValue(), glAccountData); + } else if (glAccountForSavings.equals(CashAccountsForSavings.ROUNDING_ADJUSTMENT)) { + accountMappingDetails.put(SavingProductAccountingDataParams.ROUNDING_ADJUSTMENT.getValue(), glAccountData); // Expense } else if (glAccountForSavings.equals(CashAccountsForSavings.INTEREST_ON_SAVINGS)) { accountMappingDetails.put(SavingProductAccountingDataParams.INTEREST_ON_SAVINGS.getValue(), glAccountData); diff --git a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java index fd77457cf..2fa7358c0 100644 --- a/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java +++ b/fineract-accounting/src/main/java/org/apache/fineract/accounting/producttoaccountmapping/service/SavingsProductToGLAccountMappingHelper.java @@ -106,6 +106,20 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa PortfolioProductType.SAVING); } + /** + * Create-or-merge, for an expense row a product may not have yet. + * + * The plain merge variant assumes the mapping already exists, which is true of every expense + * row that shipped with the product screen. A row added later — the rounding adjustment — is + * absent on every product created before it, so editing one of those has to create the + * mapping rather than fail looking for it. + */ + public void createOrmergeSavingsToExpenseAccountMappingChanges(final JsonElement element, final String paramName, + final Long productId, final int accountTypeId, final Map changes) { + createOrmergeProductToAccountMappingChanges(element, paramName, productId, accountTypeId, changes, GLAccountType.EXPENSE, + PortfolioProductType.SAVING); + } + /*** Abstractions for payments channel related to savings products ***/ public void savePaymentChannelToFundSourceMappings(final JsonCommand command, final JsonElement element, final Long productId, @@ -248,6 +262,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa CashAccountsForSavings.TRANSFERS_SUSPENSE.toString(), changes); createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingsProductId, CashAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); + createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), + savingsProductId, CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); break; case ACCRUAL_PERIODIC: // asset @@ -308,6 +324,8 @@ public class SavingsProductToGLAccountMappingHelper extends ProductToGLAccountMa createOrmergeSavingsToLiabilityAccountMappingChanges(element, SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), savingsProductId, AccrualAccountsForSavings.ESCHEAT_LIABILITY.getValue(), changes); + createOrmergeSavingsToExpenseAccountMappingChanges(element, SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), + savingsProductId, AccrualAccountsForSavings.ROUNDING_ADJUSTMENT.getValue(), changes); break; case ACCRUAL_UPFRONT: diff --git a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java index e79c7d784..655527918 100644 --- a/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java +++ b/fineract-core/src/main/java/org/apache/fineract/accounting/common/AccountingConstants.java @@ -269,7 +269,15 @@ public final class AccountingConstants { OVERDRAFT_PORTFOLIO_CONTROL(11), // INCOME_FROM_INTEREST(12), // LOSSES_WRITTEN_OFF(13), // - ESCHEAT_LIABILITY(14); // + ESCHEAT_LIABILITY(14), // + /** + * Where the paise dropped from a cash closure are absorbed. + * + * Cash has no paise in circulation, so a closing balance of 9,883.79 is handed over as + * 9,883 and the 0.79 has to land somewhere other than the member's own ledger. Numbered + * well clear of the accrual slots so the two sets never collide as either grows. + */ + ROUNDING_ADJUSTMENT(26); // private final Integer value; @@ -317,7 +325,9 @@ public final class AccountingConstants { FEES_RECEIVABLE(15), // PENALTIES_RECEIVABLE(16), // INTEREST_PAYABLE(17), // - INTEREST_RECEIVABLE(18); + INTEREST_RECEIVABLE(18), // + /** Where the paise dropped from a cash closure are absorbed — see the cash enum. */ + ROUNDING_ADJUSTMENT(26); private final Integer value; @@ -372,7 +382,8 @@ public final class AccountingConstants { PENALTIES_RECEIVABLE("penaltiesReceivableAccountId"), // INTEREST_RECEIVABLE("interestReceivableAccountId"), // FEES_RECEIVABLE("feesReceivableAccountId"), // - INTEREST_PAYABLE("interestPayableAccountId"); + INTEREST_PAYABLE("interestPayableAccountId"), // + ROUNDING_ADJUSTMENT("roundingAdjustmentAccountId"); private final String value; @@ -410,7 +421,8 @@ public final class AccountingConstants { FEES_RECEIVABLE("feeReceivableAccount"), // PENALTIES_RECEIVABLE("penaltyReceivableAccount"), // INTEREST_PAYABLE("interestPayableAccount"), // - INTEREST_RECEIVABLE("interestReceivableAccount"); // + INTEREST_RECEIVABLE("interestReceivableAccount"), // + ROUNDING_ADJUSTMENT("roundingAdjustmentAccount"); // private final String value; diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java index 5e29e5ca1..35af580f8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/productaccountmapping/service/ProductToGLAccountMappingWritePlatformServiceImpl.java @@ -301,6 +301,15 @@ public class ProductToGLAccountMappingWritePlatformServiceImpl implements Produc CashAccountsForSavings.ESCHEAT_LIABILITY.getValue()); } + // Optional: only mapped when the product screen supplies it. Every savings product that + // existed before this row was added has none, and a closure on one of those still has to + // work — see the closure's own fallback. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) { + this.savingsProductToGLAccountMappingHelper.saveSavingsToExpenseAccountMapping(element, + SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), savingProductId, + CashAccountsForSavings.ROUNDING_ADJUSTMENT.getValue()); + } + // advanced accounting mappings this.savingsProductToGLAccountMappingHelper.savePaymentChannelToFundSourceMappings(command, element, savingProductId, null); this.savingsProductToGLAccountMappingHelper.saveChargesToIncomeAccountMappings(command, element, savingProductId, null); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java index cfb71fca7..65056173c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkClosureRequestApiResource.java @@ -76,10 +76,13 @@ import org.springframework.transaction.annotation.Transactional; * Close Savings Account screen), and a cashier hands over the physical cash against the token, * recording the denomination breakdown. * - * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded to the - * nearest rupee for the physical hand-over; any few-paise difference is absorbed by the dedicated - * "Round Off Account" GL account via one small adjusting journal entry, never touching the member's own - * ledger (which stays exactly correct to the paisa). + * Cash has no paisa coins in circulation, so the exact (paisa-correct) closing amount is rounded DOWN + * to the whole rupee for the physical hand-over -- 9,883.79 is paid as 9,883 -- and the dropped paise + * are absorbed by the Rounding Adjustment account the savings product names, via one small adjusting + * journal entry. The member's own ledger stays exactly correct to the paisa. + * + * Down rather than to the nearest rupee, so the branch never pays out more cash than the account + * actually held: rounding 9,883.79 up to 9,884 hands over 21 paise the member never had. */ @Path("/v1/clerk/closure-requests") @Component @@ -91,6 +94,13 @@ public class ClerkClosureRequestApiResource { // and a field of the same name reads as that variable at a glance. private static final Logger LOG = LoggerFactory.getLogger(ClerkClosureRequestApiResource.class); + /** acc_product_mapping.product_type for a savings product. */ + private static final int PRODUCT_TYPE_SAVING = 2; + /** {@code CashAccountsForSavings.ROUNDING_ADJUSTMENT} */ + private static final int ROUNDING_ADJUSTMENT_ACCOUNT_TYPE = 26; + /** Where the paise go on a product that has no mapping of its own yet. */ + private static final String DEFAULT_ROUND_OFF_GL_CODE = "501200"; + private final JdbcTemplate jdbcTemplate; private final FromJsonHelper fromJsonHelper; private final PlatformSecurityContext context; @@ -170,7 +180,7 @@ public class ClerkClosureRequestApiResource { final BigDecimal closingAmount = postClosureInterest(savingsAccountId, closureDate); // Rounded the same way approve() rounds it -- so the manager sees, while it is still PENDING, // exactly what cash the cashier will end up handing over, not just the exact-to-the-paisa figure. - final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + final BigDecimal cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR); final BigDecimal roundingAmount = cashierDisbursedAmount.subtract(closingAmount); final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -316,7 +326,7 @@ public class ClerkClosureRequestApiResource { res.addProperty("total", total); // What a cashier could physically count out: no paisa coins circulate, so the hand-over is // always whole rupees (the difference lands on the Round Off Account at approval). - res.addProperty("roundedTotal", total.setScale(0, RoundingMode.HALF_UP)); + res.addProperty("roundedTotal", total.setScale(0, RoundingMode.FLOOR)); res.addProperty("interestUpToDate", true); res.addProperty("asOfDate", closureDate.toString()); return gson.toJson(res); @@ -534,12 +544,12 @@ public class ClerkClosureRequestApiResource { if (txn != null) { txnId = txn.get("id").toString(); closingAmount = new BigDecimal(txn.get("amount").toString()); - cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.HALF_UP); + cashierDisbursedAmount = closingAmount.setScale(0, RoundingMode.FLOOR); roundingAmount = cashierDisbursedAmount.subtract(closingAmount); if (roundingAmount.compareTo(BigDecimal.ZERO) != 0) { final Long officeId = ((Number) txn.get("office_id")).longValue(); - postRoundingAdjustment(officeId, roundingAmount, currentUser); + postRoundingAdjustment(officeId, roundingAmount, currentUser, savingsAccountId); } } @@ -795,11 +805,11 @@ public class ClerkClosureRequestApiResource { lines.add(VoucherLine.debitGl(controlGlId, memberSideName, accountNo, closingAmount)); final int roundingDirection = roundingAmount.compareTo(BigDecimal.ZERO); if (roundingDirection > 0) { - lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + lines.add(VoucherLine.debitGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); } lines.add(VoucherLine.creditGl(null, "Cash in Hand", null, cashierDisbursedAmount)); if (roundingDirection < 0) { - lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(), "Round Off Account", null, roundingAmount.abs())); + lines.add(VoucherLine.creditGl(resolveRoundOffGlAccount(savingsAccountId), "Round Off Account", null, roundingAmount.abs())); } final String voucherNo = voucherService.createVoucherWithLines(VoucherService.TYPE_WITHDRAWAL, @@ -824,9 +834,10 @@ public class ClerkClosureRequestApiResource { * extra cash out is a further CREDIT to cash, against a DEBIT to Round Off (the bank wears the * difference as an expense). Rounded down, both legs flip. */ - private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser) { + private void postRoundingAdjustment(final Long officeId, final BigDecimal roundingAmount, final AppUser currentUser, + final Long savingsAccountId) { final Map cashGl = resolveCashGlAccount(); - final Long roundOffGlId = resolveRoundOffGlAccount(); + final Long roundOffGlId = resolveRoundOffGlAccount(savingsAccountId); final String currencyCode = jdbcTemplate.queryForObject( "SELECT code FROM m_organisation_currency ORDER BY code LIMIT 1", String.class); final BigDecimal amount = roundingAmount.abs(); @@ -839,10 +850,15 @@ public class ClerkClosureRequestApiResource { // type_enum: 2 = DEBIT, 1 = CREDIT. if (roundingAmount.compareTo(BigDecimal.ZERO) > 0) { // Rounded up: extra cash left the drawer. Dr Round Off / Cr Cash + // + // Unreachable while the hand-over rounds down, and kept so: an amount already whole to + // the rupee produces no adjustment at all, and if the rounding rule is ever changed + // back this leg is still correct rather than silently posting the wrong way. insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); } else { - // Rounded down: less cash left the drawer than the member's ledger gave up. + // Rounded down, which is now every case: the member's ledger gave up the full paisa + // amount but less cash left the drawer, so the paise stay with the bank. // Dr Cash / Cr Round Off insertJournalEntry(officeId, cashGlId, currencyCode, transactionId, entryDate, 2, amount, currentUser); insertJournalEntry(officeId, roundOffGlId, currencyCode, transactionId, entryDate, 1, amount, currentUser); @@ -862,11 +878,39 @@ public class ClerkClosureRequestApiResource { return rows.get(0); } - private Long resolveRoundOffGlAccount() { + /** + * The GL the dropped paise are absorbed by, from the closing account's own savings product. + * + *

+ * Read from the product's Accounting tab like every other GL this closure touches, so a bank + * on its own chart of accounts posts to the head it chose. This used to be + * {@code gl_code = '501200'} written into the query, which meant a tenant whose chart has no + * such code could not close an account at all, and a bank that keeps its round-off elsewhere + * had to create a 501200 anyway. + * + *

+ * Falls back to that code where the product has no mapping yet — every product created before + * the row existed is in that position, and a closure must not fail over a few paise. + */ + private Long resolveRoundOffGlAccount(final Long savingsAccountId) { + if (savingsAccountId != null) { + final List mapped = jdbcTemplate.queryForList(""" + SELECT m.gl_account_id + FROM acc_product_mapping m + JOIN m_savings_account sa ON sa.product_id = m.product_id + WHERE sa.id = ? AND m.product_type = ? AND m.financial_account_type = ? + LIMIT 1 + """, Long.class, savingsAccountId, PRODUCT_TYPE_SAVING, ROUNDING_ADJUSTMENT_ACCOUNT_TYPE); + if (!mapped.isEmpty()) { + return mapped.get(0); + } + } final List ids = jdbcTemplate.queryForList( - "SELECT id FROM acc_gl_account WHERE gl_code = '501200' LIMIT 1", Long.class); + "SELECT id FROM acc_gl_account WHERE gl_code = ? LIMIT 1", Long.class, DEFAULT_ROUND_OFF_GL_CODE); if (ids.isEmpty()) { - throw apiError(500, "Round Off Account (gl_code 501200) is not configured for this tenant yet."); + throw apiError(500, "No Rounding Adjustment account is mapped on the savings product, and the default " + + "Round Off Account (gl_code " + DEFAULT_ROUND_OFF_GL_CODE + ") does not exist in this tenant. " + + "Map one on the product's Accounting tab."); } return ids.get(0); } diff --git a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java index c3502162f..8ee4f3417 100644 --- a/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java +++ b/fineract-savings/src/main/java/org/apache/fineract/portfolio/savings/data/SavingsProductDataValidator.java @@ -105,6 +105,7 @@ public class SavingsProductDataValidator { SavingProductAccountingParams.OVERDRAFT_PORTFOLIO_CONTROL.getValue(), SavingProductAccountingParams.LOSSES_WRITTEN_OFF.getValue(), SavingProductAccountingParams.INCOME_FROM_INTEREST.getValue(), SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue(), SavingProductAccountingParams.INTEREST_RECEIVABLE.getValue(), + SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), isDormancyTrackingActiveParamName, daysToDormancyParamName, daysToInactiveParamName, daysToEscheatParamName, allowOverdraftParamName, overdraftLimitParamName, nominalAnnualInterestRateOverdraftParamName, minOverdraftForInterestCalculationParamName, SavingsApiConstants.minRequiredBalanceParamName, @@ -460,6 +461,15 @@ public class SavingsProductDataValidator { baseDataValidator.reset().parameter(SavingProductAccountingParams.ESCHEAT_LIABILITY.getValue()) .value(escheatLiabilityAccountId).notNull().integerGreaterThanZero(); } + + // Optional — a product saved without one still closes, on the fallback the closure + // carries. Only the shape is checked, so clearing the field is allowed. + if (this.fromApiJsonHelper.parameterExists(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element)) { + final Long roundingAdjustmentAccountId = this.fromApiJsonHelper + .extractLongNamed(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue(), element); + baseDataValidator.reset().parameter(SavingProductAccountingParams.ROUNDING_ADJUSTMENT.getValue()) + .value(roundingAdjustmentAccountId).ignoreIfNull().integerGreaterThanZero(); + } } validateOverdraftParams(baseDataValidator, element);