From 26d4c241c764454ac289cb4a8ed13840055255c8 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Thu, 24 Sep 2026 15:54:03 +0530 Subject: [PATCH] fix(clerk-disbursement): validate savings withdrawal eligibility when a request is raised A clerk raising a disbursement request with a Dr leg against a savings account had no way to know the withdrawal would be refused (inactive account, still locked-in, or insufficient balance against its own minimum) until a manager tried to approve it and the whole request bounced back. Checks the same conditions SavingsAccount.withdraw() enforces at entry time instead, against the same columns, so the clerk sees the real reason immediately. Co-Authored-By: Claude Sonnet 5 --- .../ClerkDisbursementRequestApiResource.java | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 08d596d26..e75955127 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -177,6 +177,15 @@ public class ClerkDisbursementRequestApiResource { kycRestrictionGuard.check(entry.get("savingsAccountId").getAsLong(), "Dr".equals(dbCr) ? KycRestrictionGuard.Operation.TRANSFER : KycRestrictionGuard.Operation.CREDIT); } + // A Dr leg against a savings account is a withdrawal, and the clerk who raises it should + // not learn the account can't pay out until a manager tries to approve it and the whole + // request bounces back generic and reversed. Every reason SavingsAccount.withdraw() would + // refuse this later -- inactive, closed, or still inside its lock-in period -- is checked + // here too, against the same columns, so the clerk sees the real reason at entry time. + if ("Dr".equals(dbCr) && "SAVINGS".equals(entryAccountType) + && entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull()) { + validateSavingsWithdrawalEligible(entry.get("savingsAccountId").getAsLong(), entryAmt); + } if ("Dr".equals(dbCr)) { totalDr = totalDr.add(entryAmt); } else { @@ -1179,6 +1188,51 @@ public class ClerkDisbursementRequestApiResource { .findFirst().orElse(null); } + /** Savings status_enum for an account still open for business (Fineract's SavingsAccountStatusType.ACTIVE). */ + private static final int SAVINGS_STATUS_ACTIVE = 300; + + /** + * The same refusals {@code SavingsAccount.withdraw()} raises at approval time -- account not + * active, or still inside its lock-in period -- checked here against the same columns, so the + * clerk sees the real reason when the request is raised rather than a generic failure once a + * manager tries to approve it. Balance is checked too, against the account's own minimum, since + * an entry that can never post is no more usable sitting in the approval queue than one refused + * outright. + */ + private void validateSavingsWithdrawalEligible(final Long savingsAccountId, final BigDecimal amount) { + final List> rows = jdbcTemplate.queryForList(""" + SELECT account_no, status_enum, lockedin_until_date_derived, account_balance_derived, + min_required_balance, enforce_min_required_balance + FROM m_savings_account WHERE id = ? + """, savingsAccountId); + if (rows.isEmpty()) { + return; // not found here is not this check's job -- the id/type mismatch surfaces elsewhere + } + final Map row = rows.get(0); + final String accountNo = (String) row.get("account_no"); + final Number statusEnum = (Number) row.get("status_enum"); + if (statusEnum == null || statusEnum.intValue() != SAVINGS_STATUS_ACTIVE) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": account is not active."); + } + final java.sql.Date lockedUntil = (java.sql.Date) row.get("lockedin_until_date_derived"); + if (lockedUntil != null) { + final LocalDate today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()); + final LocalDate lockedUntilDate = lockedUntil.toLocalDate(); + if (today.isBefore(lockedUntilDate)) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + + ": account is locked-in until " + lockedUntilDate + "."); + } + } + final BigDecimal balance = (BigDecimal) row.get("account_balance_derived"); + final boolean enforceMin = Boolean.TRUE.equals(row.get("enforce_min_required_balance")); + final BigDecimal minRequired = enforceMin && row.get("min_required_balance") != null + ? (BigDecimal) row.get("min_required_balance") : BigDecimal.ZERO; + if (balance != null && balance.subtract(amount).compareTo(minRequired) < 0) { + throw apiError(422, "Withdrawal not allowed for savings account " + accountNo + ": available balance (" + + balance.subtract(minRequired) + ") is less than the requested amount (" + amount + ")."); + } + } + private void addDisbursementVoucherLines(final Map record, final JsonArray creditEntries, final Long sourceGlId, final String requestType) {