feat(savings): support rounding adjustment GL mapping and floor rounding on clerk closure
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 - 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 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 Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (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 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
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 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 Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Documentation build / build (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

This commit is contained in:
2026-09-11 10:48:50 +05:30
parent 5cc5d680f2
commit 3d196276c8
6 changed files with 117 additions and 20 deletions
@@ -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);
@@ -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<String, Object> 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:
@@ -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;
@@ -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);
@@ -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<String, Object> 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.
*
* <p>
* 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.
*
* <p>
* 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<Long> 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<Long> 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);
}
@@ -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);