From 04d1eb20f0a26d3683cf01957e5209f386c7c086 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Wed, 14 Oct 2026 13:51:01 +0530 Subject: [PATCH] feat(clerk-withdrawal): GL withdrawal slips through request, approval and cashier payment A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) now follows the same maker-checker path as a savings withdrawal: - the request carries gl_account_id, contra_gl_id, office_id and currency_code (new nullable columns, changeset 0600) instead of a savings account; - at submit it is validated: the paying GL must be a mapped cash vault GL and the slip's own GL must not be a cash GL; - at manager approval it posts Dr the GL / Cr the cash vault GL as a journal entry; - the cashier pays it against its token, and the denomination and voucher lines are recorded under the journal entry's id. KYC debit checks are skipped for GL requests. Co-Authored-By: Claude Sonnet 5.5 --- .../ClerkWithdrawalRequestApiResource.java | 174 ++++++++++++++++-- .../tenant/final-changelog-tenant.xml | 1 + .../0600_clerk_withdrawal_request_gl.xml | 28 +++ 3 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java index d579ec92e..6455ba4b6 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkWithdrawalRequestApiResource.java @@ -48,6 +48,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.apache.fineract.accounting.common.AccountingConstants.FinancialActivity; import org.apache.fineract.commands.domain.CommandWrapper; import org.apache.fineract.commands.service.CommandWrapperBuilder; import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService; @@ -110,11 +111,20 @@ public class ClerkWithdrawalRequestApiResource { final String tokNo = fromJsonHelper.extractStringNamed("tokNo", root); final String txnDate = fromJsonHelper.extractStringNamed("txnDate", root); final String denominationData = fromJsonHelper.extractStringNamed("denominationData", root); + // A GL withdrawal slip (e.g. Sundry Creditors paid out in cash) carries the GL and the cash GL it is + // paid from instead of a savings account; it is approved and disbursed exactly like any other slip. + final boolean isGl = "GL".equalsIgnoreCase(accountType); + final Long glAccountId = fromJsonHelper.extractLongNamed("glAccountId", root); + final Long contraGlId = fromJsonHelper.extractLongNamed("contraGlId", root); + final Long officeId = fromJsonHelper.extractLongNamed("officeId", root); + final String currencyCode = fromJsonHelper.extractStringNamed("currencyCode", root); if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); } - if (savingsAccountId == null) { + if (isGl) { + validateGlWithdrawalAccounts(glAccountId, contraGlId); + } else if (savingsAccountId == null) { throw apiError(400, "Savings account id is required for a withdrawal request"); } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { @@ -124,7 +134,9 @@ public class ClerkWithdrawalRequestApiResource { throw apiError(400, "Token number (TOK. NO.) is required"); } validateTokenUniqueToday(tokNo, null); - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGl) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null); @@ -132,8 +144,9 @@ public class ClerkWithdrawalRequestApiResource { INSERT INTO m_clerk_withdrawal_request (account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, tok_no, txn_date, - status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, denomination_data, activity_log, submitted_at, + gl_account_id, contra_gl_id, office_id, currency_code) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -142,7 +155,9 @@ public class ClerkWithdrawalRequestApiResource { txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - denominationData, activityLog, Timestamp.from(Instant.now()) + denominationData, activityLog, Timestamp.from(Instant.now()), + isGl ? glAccountId : null, isGl ? contraGlId : null, isGl ? (officeId != null ? officeId : 1L) : null, + isGl ? (currencyCode != null ? currencyCode : "INR") : null ); // Globally unique voucher number from the central m_voucher register @@ -198,7 +213,8 @@ public class ClerkWithdrawalRequestApiResource { + " txn_date, status, clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username, rejection_reason, txn_id," + " denomination_data, activity_log, submitted_at, actioned_at," - + " cashier_id, disbursed_by, disbursed_at, voucher_no" + + " cashier_id, disbursed_by, disbursed_at, voucher_no," + + " gl_account_id, contra_gl_id, office_id, currency_code" + " FROM m_clerk_withdrawal_request " + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray()); @@ -252,7 +268,8 @@ public class ClerkWithdrawalRequestApiResource { payment_type_name, description, tok_no, txn_date, status, clerk_name, manager_name, manager_username, rejection_reason, txn_id, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request """ + where + " ORDER BY submitted_at DESC LIMIT ? OFFSET ?", pageParams.toArray() @@ -276,7 +293,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { @@ -295,7 +313,8 @@ public class ClerkWithdrawalRequestApiResource { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, amount, payment_type_id, payment_type_name, description, tok_no, status, - clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no + clerk_id, clerk_name, manager_id, manager_name, submitted_at, actioned_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE tok_no = ? AND CAST(submitted_at AS DATE) = CURRENT_DATE ORDER BY submitted_at DESC LIMIT 1 @@ -455,13 +474,16 @@ public class ClerkWithdrawalRequestApiResource { "Maker-checker: you cannot approve a withdrawal request you submitted yourself. Ask another manager to review it."); } + final boolean isGlRequest = "GL".equalsIgnoreCase((String) record.get("account_type")); final Long savingsAccountId = record.get("savings_account_id") != null ? ((Number) record.get("savings_account_id")).longValue() : null; // Checked again on approval, not only on submission: a request can sit in the queue for days, // and the account can cross the dormancy threshold while it waits. The savings withdrawal // below refuses a dormant account anyway, but that failure would surface to the manager as a // rejected transaction after the approval was recorded; this says why, before anything moves. - kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + if (!isGlRequest) { + kycRestrictionGuard.check(savingsAccountId, KycRestrictionGuard.Operation.DEBIT); + } final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); @@ -469,7 +491,11 @@ public class ClerkWithdrawalRequestApiResource { final String description = (String) record.get("description"); String txnId = null; - if (savingsAccountId != null) { + if (isGlRequest) { + // Dr the GL account, Cr the branch cash GL -- posted here, at approval, like the savings withdrawal. + validateGlWithdrawalAccounts(toLong(record.get("gl_account_id")), toLong(record.get("contra_gl_id"))); + txnId = executeGlWithdrawalJournal(record, amount, ptId, txnDateStr, description); + } else if (savingsAccountId != null) { final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId, (String) record.get("account_no")); txnId = isCurrentAccount ? executeCurrentAccountWithdrawal(savingsAccountId, amount, ptId, txnDateStr, description) @@ -477,14 +503,18 @@ public class ClerkWithdrawalRequestApiResource { } // Link voucher to the created savings txn and stamp voucher_no on it - if (txnId != null) { + if (txnId != null && !isGlRequest) { voucherService.linkSavingsTransaction( VoucherService.REF_CLERK_WITHDRAWAL_REQUEST, id, Long.valueOf(txnId)); } // The sides of the slip. A withdrawal is money leaving the member, so their account is // debited and the till that pays it out is credited. - addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + if (isGlRequest) { + addGlWithdrawalVoucherLines(record, amount); + } else { + addWithdrawalVoucherLines(record, savingsAccountId, amount, txnId); + } final String updatedLog = buildActivityLog( (String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null @@ -689,8 +719,8 @@ public class ClerkWithdrawalRequestApiResource { if (denomMap == null || denomMap.size() == 0) return; final Object txnIdObj = record.get("txn_id"); - final Long txnId = txnIdObj != null ? Long.valueOf(txnIdObj.toString()) : null; - if (txnId == null) return; // no savings txn linked — cannot record denomination + final Long txnId = txnIdObj != null ? parseTxnKey(txnIdObj.toString()) : null; + if (txnId == null) return; // no txn linked — cannot record denomination final Object saObj = record.get("savings_account_id"); final Long savingsAccountId = saObj != null ? Long.valueOf(saObj.toString()) : null; @@ -793,6 +823,117 @@ public class ClerkWithdrawalRequestApiResource { return rows.isEmpty() ? null : rows.get(0); } + private static Long toLong(final Object value) { + return value instanceof Number n ? n.longValue() : null; + } + + /** + * A savings transaction id is numeric; a GL journal entry's is a hex string. Both are reduced to one + * Long the same way CashierGlDenominationApiResource does, so a GL withdrawal's notes group under + * the id of its own journal entry in the cashier's position and closing report. + */ + private Long parseTxnKey(final String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + try { + return Long.valueOf(raw); + } catch (final NumberFormatException ignored) { + try { + final String hex = raw.length() > 15 ? raw.substring(raw.length() - 15) : raw; + return Long.parseUnsignedLong(hex, 16); + } catch (final NumberFormatException ignored2) { + return null; + } + } + } + + /** A GL withdrawal pays cash out of a GL account: the account must not itself be a cash GL, and the + * GL it is paid from must be one of the branch's mapped cash accounts. */ + private void validateGlWithdrawalAccounts(final Long glAccountId, final Long contraGlId) { + if (glAccountId == null || contraGlId == null) { + throw apiError(400, "glAccountId and contraGlId are required for a GL withdrawal request"); + } + final List cashGlIds = jdbcTemplate.queryForList( + "SELECT gl_account_id FROM acc_gl_financial_activity_account WHERE financial_activity_type IN (?, ?)", + Long.class, FinancialActivity.CASH_AT_MAINVAULT.getValue(), FinancialActivity.CASH_AT_TELLER.getValue()); + if (!cashGlIds.contains(contraGlId)) { + throw apiError(400, "A GL withdrawal must be paid from the branch cash vault GL."); + } + if (cashGlIds.contains(glAccountId)) { + throw apiError(400, "The cash vault itself cannot be the GL account of a withdrawal slip."); + } + } + + /** Dr the GL account, Cr the cash GL, through the journal-entry command (not the HTTP API, which is + * switched off for manual postings). Returns the journal entry's transaction id. */ + private String executeGlWithdrawalJournal(final Map record, final BigDecimal amount, + final Object paymentTypeId, final String txnDateStr, final String note) { + final String today = LocalDate.now(DateUtils.getDateTimeZoneOfTenant()).format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + final JsonObject debit = new JsonObject(); + debit.addProperty("glAccountId", toLong(record.get("gl_account_id"))); + debit.addProperty("amount", amount); + final JsonObject credit = new JsonObject(); + credit.addProperty("glAccountId", toLong(record.get("contra_gl_id"))); + credit.addProperty("amount", amount); + final JsonArray debits = new JsonArray(); + debits.add(debit); + final JsonArray credits = new JsonArray(); + credits.add(credit); + + final Object office = record.get("office_id"); + final Object currency = record.get("currency_code"); + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", office instanceof Number n ? n.longValue() : 1L); + payload.addProperty("transactionDate", date); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", currency != null ? currency.toString() : "INR"); + if (note != null && !note.isBlank()) { + payload.addProperty("comments", note); + } + if (paymentTypeId != null) { + payload.addProperty("paymentTypeId", ((Number) paymentTypeId).longValue()); + } + payload.add("credits", credits); + payload.add("debits", debits); + + final CommandWrapper commandRequest = new CommandWrapperBuilder().createJournalEntry().withJson(payload.toString()).build(); + // The request raised its own voucher at submission; the journal command must not raise a second. + final CommandProcessingResult result = voucherService + .withOwnVoucher(() -> commandsSourceWritePlatformService.logCommandSource(commandRequest)); + return result.getTransactionId(); + } + + /** The two sides of an approved GL withdrawal on its voucher: the GL account is debited, the cash GL credited. */ + private void addGlWithdrawalVoucherLines(final Map record, final BigDecimal amount) { + final String voucherNo = (String) record.get("voucher_no"); + if (voucherNo == null || voucherNo.isBlank()) { + return; + } + try { + final Long glId = toLong(record.get("gl_account_id")); + final Long contraId = toLong(record.get("contra_gl_id")); + final Map gl = glAccountForVoucher(glId); + final Map contra = glAccountForVoucher(contraId); + voucherService.addLines(voucherNo, List.of( + VoucherLine.debitGl(glId, (String) gl.get("name"), (String) gl.get("gl_code"), amount), + VoucherLine.creditGl(contraId, (String) contra.getOrDefault("name", "Cash in Hand"), (String) contra.get("gl_code"), amount))); + } catch (final RuntimeException e) { + LOG.error("Could not record voucher lines for GL withdrawal request voucher {}", voucherNo, e); + } + } + + private Map glAccountForVoucher(final Long glAccountId) { + if (glAccountId == null) { + return Map.of(); + } + return jdbcTemplate.queryForList("SELECT name, gl_code FROM acc_gl_account WHERE id = ?", glAccountId).stream().findFirst() + .orElse(Map.of()); + } + private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" SELECT id, account_no, account_type, savings_account_id, client_id, client_name, @@ -800,7 +941,8 @@ public class ClerkWithdrawalRequestApiResource { txn_date, status, clerk_id, clerk_name, clerk_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, denomination_data, activity_log, submitted_at, actioned_at, - cashier_id, disbursed_by, disbursed_at, voucher_no + cashier_id, disbursed_by, disbursed_at, voucher_no, + gl_account_id, contra_gl_id, office_id, currency_code FROM m_clerk_withdrawal_request WHERE id = ? """, id); if (rows.isEmpty()) { diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml index 4a2d7b893..53762ea3e 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/final-changelog-tenant.xml @@ -174,4 +174,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml new file mode 100644 index 000000000..ec5d99201 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0600_clerk_withdrawal_request_gl.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + +