From 3e1abaef03137275a2be0a2541ec974a29e06b5b Mon Sep 17 00:00:00 2001 From: Jaanu423 Date: Mon, 15 Jun 2026 14:59:28 +0530 Subject: [PATCH] fix(cashier): fix NumberFormatException on GL deposit approval and add GL maker-checker flow - Use parseTxnIdToLong() for voucherService.linkSavingsTransaction() to handle hex GL txnId (e.g. "a2070e542bab") without NumberFormatException - Add GL deposit maker-checker: submit stores gl_account_id, contra_gl_id, office_id, currency_code; approve routes to executeGlJournalEntry() - Save denomination at submit time (cash physically received); update txn_id to real Fineract ID on approve, delete on reject - Add Liquibase migration 0331 to add GL columns to m_cashier_deposit_request Co-Authored-By: Claude Sonnet 4.6 --- .../api/CashierDepositRequestApiResource.java | 269 ++++++++++++++++-- .../db/changelog/tenant/changelog-tenant.xml | 1 + ..._gl_columns_to_cashier_deposit_request.xml | 20 ++ 3 files changed, 266 insertions(+), 24 deletions(-) create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0331_add_gl_columns_to_cashier_deposit_request.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java index 2f1eab9f7..930c06782 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/cashierdeposit/api/CashierDepositRequestApiResource.java @@ -85,7 +85,12 @@ public class CashierDepositRequestApiResource { final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root); final String accountType = Optional.ofNullable(fromJsonHelper.extractStringNamed("accountType", root)).orElse("SAVINGS"); + final boolean isGlType = "GL".equalsIgnoreCase(accountType); final Long savingsAccountId = fromJsonHelper.extractLongNamed("savingsAccountId", root); + 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); final Long clientId = fromJsonHelper.extractLongNamed("clientId", root); final String clientName = fromJsonHelper.extractStringNamed("clientName", root); final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root)); @@ -100,9 +105,12 @@ public class CashierDepositRequestApiResource { if (accountNo == null || accountNo.isBlank()) { throw apiError(400, "Account number is required"); } - if (savingsAccountId == null) { + if (!isGlType && savingsAccountId == null) { throw apiError(400, "Savings account id is required for a deposit request"); } + if (isGlType && (glAccountId == null || contraGlId == null)) { + throw apiError(400, "glAccountId and contraGlId are required for GL deposit requests"); + } if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw apiError(400, "Amount must be greater than zero"); } @@ -112,14 +120,17 @@ public class CashierDepositRequestApiResource { final Long newId = jdbcTemplate.queryForObject(""" INSERT INTO m_cashier_deposit_request - (account_no, account_type, savings_account_id, client_id, client_name, amount, + (account_no, account_type, savings_account_id, gl_account_id, contra_gl_id, office_id, currency_code, + client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, - accountNo, accountType, savingsAccountId, clientId, clientName, amount, + accountNo, accountType, savingsAccountId, glAccountId, contraGlId, + officeId, currencyCode, + clientId, clientName, amount, paymentTypeId, paymentTypeName, description, chequeNo, bankName, txnDate != null ? Date.valueOf(txnDate) : null, "PENDING", @@ -132,6 +143,14 @@ public class CashierDepositRequestApiResource { VoucherService.TYPE_DEPOSIT, VoucherService.REF_CASHIER_DEPOSIT_REQUEST, newId, amount, currentUser); jdbcTemplate.update("UPDATE m_cashier_deposit_request SET voucher_no = ? WHERE id = ?", voucherNo, newId); + // Save denomination immediately — cashier physically received this cash at submit time. + // We use newId as a temporary txn_id; approve() will update it to the real Fineract txn_id. + // reject() will delete these records so the vault position is reversed. + if (denominationData != null && !denominationData.isBlank()) { + savePendingDenomination(denominationData, newId, savingsAccountId, accountNo, clientId, + currentUser.getId(), currentUser.getUsername()); + } + return gson.toJson(Map.of("resourceId", newId, "status", "PENDING", "voucherNo", voucherNo)); } @@ -328,26 +347,48 @@ public class CashierDepositRequestApiResource { "Maker-checker: you cannot approve a deposit request you submitted yourself. Ask another manager to review it."); } - final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + final Object saObj = record.get("savings_account_id"); + final Long savingsAccountId = saObj != null ? ((Number) saObj).longValue() : null; final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; final String description = (String) record.get("description"); - // Determine real account type by checking which table owns this ID. - // Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS' - // even for current accounts (submitted before the frontend fix). - final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); + final String storedAccountType = (String) record.get("account_type"); + final String txnId; + if ("GL".equalsIgnoreCase(storedAccountType)) { + final Object glObj = record.get("gl_account_id"); + final Object contraObj = record.get("contra_gl_id"); + final Object officeObj = record.get("office_id"); + final Object ccObj = record.get("currency_code"); + if (glObj == null || contraObj == null) { + throw apiError(400, "GL account information missing on this deposit request."); + } + final Long glAccountId = ((Number) glObj).longValue(); + final Long contraGlId = ((Number) contraObj).longValue(); + final Long glOfficeId = officeObj != null ? ((Number) officeObj).longValue() : 1L; + final String currencyCode = ccObj != null ? ccObj.toString() : "INR"; + txnId = executeGlJournalEntry(glAccountId, contraGlId, glOfficeId, currencyCode, + amount, ptId, txnDateStr, description, true); + } else { + // Determine real account type by checking which table owns this ID. + // Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS' + // even for current accounts (submitted before the frontend fix). + final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); + txnId = isCurrentAccount + ? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) + : executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); + } - final String txnId = isCurrentAccount - ? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) - : executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description); - - // Link voucher to the created savings txn and stamp voucher_no on it + // Link voucher to the created savings txn and stamp voucher_no on it. + // parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry). if (txnId != null) { - voucherService.linkSavingsTransaction( - VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, Long.valueOf(txnId)); + final Long voucherTxnId = parseTxnIdToLong(txnId); + if (voucherTxnId != null) { + voucherService.linkSavingsTransaction( + VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, voucherTxnId); + } } final String updatedLog = buildActivityLog( @@ -369,11 +410,15 @@ public class CashierDepositRequestApiResource { throw apiError(409, "This deposit request was already actioned by another manager."); } - // Record denominations so the CASHIER's vault position reflects cash taken in. - // Officer = the cashier who physically received the cash, not the approving manager. - final String denominationData = (String) record.get("denomination_data"); - if (txnId != null && denominationData != null && !denominationData.isBlank()) { - saveDepositDenomination(denominationData, record, Long.valueOf(txnId)); + // Denomination was already saved at submit time (cashier physically received cash then). + // Now update the temporary txn_id (request id) to the real Fineract transaction id. + // parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry). + if (txnId != null) { + final Long realTxnId = parseTxnIdToLong(txnId); + if (realTxnId != null) { + jdbcTemplate.update( + "UPDATE m_savings_txn_denomination SET txn_id = ? WHERE txn_id = ?", realTxnId, id); + } } final JsonObject res = new JsonObject(); @@ -423,6 +468,10 @@ public class CashierDepositRequestApiResource { throw apiError(409, "This deposit request was already actioned by another manager."); } + // Reverse denomination — cashier physically returned cash to customer on rejection. + // The temporary denomination records (saved at submit time with txn_id = request id) are removed. + jdbcTemplate.update("DELETE FROM m_savings_txn_denomination WHERE txn_id = ?", id); + return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); } @@ -446,14 +495,15 @@ public class CashierDepositRequestApiResource { throw apiError(409, "Only APPROVED requests can be reprocessed. Current status: " + record.get("status")); } - final Long savingsAccountId = ((Number) record.get("savings_account_id")).longValue(); + final Object saObjR = record.get("savings_account_id"); + final Long savingsAccountId = saObjR != null ? ((Number) saObjR).longValue() : null; final BigDecimal amount = new BigDecimal(record.get("amount").toString()); final Object ptId = record.get("payment_type_id"); final Object txnDateObj = record.get("txn_date"); final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; final String description = (String) record.get("description"); - if (!isCurrentAccountById(savingsAccountId)) { + if (savingsAccountId == null || !isCurrentAccountById(savingsAccountId)) { throw apiError(400, "This account is not a current account. Reprocess is only needed for current account deposits."); } @@ -476,7 +526,9 @@ public class CashierDepositRequestApiResource { private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" - SELECT id, voucher_no, account_no, account_type, savings_account_id, client_id, client_name, + SELECT id, voucher_no, account_no, account_type, savings_account_id, + gl_account_id, contra_gl_id, office_id, currency_code, + client_id, client_name, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name, txn_date, status, cashier_id, cashier_name, cashier_username, manager_id, manager_name, manager_username, rejection_reason, txn_id, @@ -556,12 +608,181 @@ public class CashierDepositRequestApiResource { } } + // Parses a Fineract transaction id string to Long. + // Savings/current txn ids are decimal; GL journal entry ids are hex strings. + private Long parseTxnIdToLong(final String txnId) { + if (txnId == null || txnId.isBlank()) return null; + try { + return Long.valueOf(txnId); + } catch (NumberFormatException ignored) { + try { + final String hex = txnId.length() > 15 ? txnId.substring(txnId.length() - 15) : txnId; + return Long.parseUnsignedLong(hex, 16); + } catch (NumberFormatException ignored2) { + return null; + } + } + } + + private String executeGlJournalEntry( + final Long glAccountId, final Long contraGlId, final Long officeId, + final String currencyCode, final BigDecimal amount, + final Object paymentTypeId, final String txnDateStr, final String note, + final boolean isDeposit) { + + final String today = LocalDate.now().format(DateTimeFormatter.ofPattern("dd MMMM yyyy")); + final String date = txnDateStr != null && !txnDateStr.isBlank() ? formatForFineract(txnDateStr) : today; + + // Deposit (CR): cashier receives cash → credit selected GL, debit cash vault (contra) + // Withdrawal (DR): cashier pays cash → debit selected GL, credit cash vault (contra) + final JsonObject creditEntry = new JsonObject(); + creditEntry.addProperty("glAccountId", isDeposit ? glAccountId : contraGlId); + creditEntry.addProperty("amount", amount); + + final JsonObject debitEntry = new JsonObject(); + debitEntry.addProperty("glAccountId", isDeposit ? contraGlId : glAccountId); + debitEntry.addProperty("amount", amount); + + final com.google.gson.JsonArray credits = new com.google.gson.JsonArray(); + credits.add(creditEntry); + final com.google.gson.JsonArray debits = new com.google.gson.JsonArray(); + debits.add(debitEntry); + + final JsonObject payload = new JsonObject(); + payload.addProperty("officeId", officeId); + payload.addProperty("transactionDate", date); + payload.addProperty("dateFormat", "dd MMMM yyyy"); + payload.addProperty("locale", "en"); + payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "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(); + + final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(commandRequest); + return result.getTransactionId() != null ? result.getTransactionId() : null; + } + + private void saveGlDenomination(final String denominationData, + final Map record, final String txnIdRaw) { + final JsonObject denomMap; + try { + denomMap = gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + return; + } + if (denomMap == null || denomMap.size() == 0) return; + + // Convert hex transaction ID to Long (same logic as CashierGlDenominationApiResource) + Long txnId = null; + if (txnIdRaw != null && !txnIdRaw.isBlank()) { + try { + txnId = Long.valueOf(txnIdRaw); + } catch (NumberFormatException ignored) { + try { + final String hex = txnIdRaw.length() > 15 ? txnIdRaw.substring(txnIdRaw.length() - 15) : txnIdRaw; + txnId = Long.parseUnsignedLong(hex, 16); + } catch (NumberFormatException ignored2) { + txnId = null; + } + } + } + + final String accountNo = (String) record.get("account_no"); + final Object cashierIdObj = record.get("cashier_id"); + final Long cashierId = cashierIdObj != null ? Long.valueOf(cashierIdObj.toString()) : null; + final String cashierUsername = (String) record.get("cashier_username"); + + for (final Map.Entry entry : denomMap.entrySet()) { + final int count; + try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } + if (count <= 0) continue; + + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final String kind = parts[0]; + final int value; + try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } + + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" + + " AND (deleted IS NULL OR deleted = false) LIMIT 1", + Long.class, value, kind); + if (ids.isEmpty()) continue; + final Long denomId = ids.get(0); + + jdbcTemplate.update(""" + INSERT INTO m_savings_txn_denomination + (txn_id, savings_account_id, account_no, client_id, denomination_id, + denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) + VALUES (?, NULL, ?, NULL, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?) + """, + txnId, accountNo, denomId, + value, count, BigDecimal.valueOf((long) value * count), + cashierId, cashierUsername + ); + } + } + /** * Saves the denomination breakdown the cashier entered at submit time into * m_savings_txn_denomination once the deposit is approved, so the cashier's * vault position report reflects the cash received at the counter. * denominationData format: {"note:500": 2, "coin:1": 10} (kind:value → count) */ + // Called at submit time — uses request id as temporary txn_id. + // approve() updates txn_id to real Fineract id; reject() deletes these records. + private void savePendingDenomination(final String denominationData, + final Long requestId, final Long savingsAccountId, final String accountNo, + final Long clientId, final Long cashierId, final String cashierUsername) { + final JsonObject denomMap; + try { + denomMap = gson.fromJson(denominationData, JsonObject.class); + } catch (Exception e) { + return; + } + if (denomMap == null || denomMap.size() == 0) return; + + for (final Map.Entry entry : denomMap.entrySet()) { + final int count; + try { count = entry.getValue().getAsInt(); } catch (Exception e) { continue; } + if (count <= 0) continue; + + final String[] parts = entry.getKey().split(":", 2); + if (parts.length != 2) continue; + final String kind = parts[0]; + final int value; + try { value = Integer.parseInt(parts[1]); } catch (Exception e) { continue; } + + final List ids = jdbcTemplate.queryForList( + "SELECT id FROM m_denomination_master WHERE denomination = ? AND kind = ?" + + " AND (deleted IS NULL OR deleted = false) LIMIT 1", + Long.class, value, kind); + if (ids.isEmpty()) continue; + final Long denomId = ids.get(0); + + jdbcTemplate.update(""" + INSERT INTO m_savings_txn_denomination + (txn_id, savings_account_id, account_no, client_id, denomination_id, + denomination_value, count, total_amount, txn_date, txn_type, officer_id, officer_username) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_DATE, 'DEPOSIT', ?, ?) + """, + requestId, savingsAccountId, accountNo, clientId, denomId, + value, count, BigDecimal.valueOf((long) value * count), + cashierId, cashierUsername + ); + } + } + private void saveDepositDenomination(final String denominationData, final Map record, final Long txnId) { final JsonObject denomMap; diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 118f3c35f..ce2e851e2 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -358,4 +358,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0331_add_gl_columns_to_cashier_deposit_request.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0331_add_gl_columns_to_cashier_deposit_request.xml new file mode 100644 index 000000000..b6e457d4b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0331_add_gl_columns_to_cashier_deposit_request.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + +