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 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-15 14:59:28 +05:30
co-authored by Claude Sonnet 4.6
parent 40449ef0f7
commit 3e1abaef03
3 changed files with 266 additions and 24 deletions
@@ -85,7 +85,12 @@ public class CashierDepositRequestApiResource {
final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root); final String accountNo = fromJsonHelper.extractStringNamed("accountNo", root);
final String accountType = Optional.ofNullable(fromJsonHelper.extractStringNamed("accountType", root)).orElse("SAVINGS"); 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 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 Long clientId = fromJsonHelper.extractLongNamed("clientId", root);
final String clientName = fromJsonHelper.extractStringNamed("clientName", root); final String clientName = fromJsonHelper.extractStringNamed("clientName", root);
final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root)); final BigDecimal amount = parseBigDecimal(fromJsonHelper.extractStringNamed("amount", root));
@@ -100,9 +105,12 @@ public class CashierDepositRequestApiResource {
if (accountNo == null || accountNo.isBlank()) { if (accountNo == null || accountNo.isBlank()) {
throw apiError(400, "Account number is required"); 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"); 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) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Amount must be greater than zero"); throw apiError(400, "Amount must be greater than zero");
} }
@@ -112,14 +120,17 @@ public class CashierDepositRequestApiResource {
final Long newId = jdbcTemplate.queryForObject(""" final Long newId = jdbcTemplate.queryForObject("""
INSERT INTO m_cashier_deposit_request 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, 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) status, cashier_id, cashier_name, cashier_username, denomination_data, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id RETURNING id
""", """,
Long.class, Long.class,
accountNo, accountType, savingsAccountId, clientId, clientName, amount, accountNo, accountType, savingsAccountId, glAccountId, contraGlId,
officeId, currencyCode,
clientId, clientName, amount,
paymentTypeId, paymentTypeName, description, chequeNo, bankName, paymentTypeId, paymentTypeName, description, chequeNo, bankName,
txnDate != null ? Date.valueOf(txnDate) : null, txnDate != null ? Date.valueOf(txnDate) : null,
"PENDING", "PENDING",
@@ -132,6 +143,14 @@ public class CashierDepositRequestApiResource {
VoucherService.TYPE_DEPOSIT, VoucherService.REF_CASHIER_DEPOSIT_REQUEST, newId, amount, currentUser); 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); 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)); 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."); "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 BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id"); final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date"); final Object txnDateObj = record.get("txn_date");
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
final String description = (String) record.get("description"); final String description = (String) record.get("description");
// Determine real account type by checking which table owns this ID. final String storedAccountType = (String) record.get("account_type");
// Do NOT rely solely on the stored account_type field — old records may have 'SAVINGS' final String txnId;
// even for current accounts (submitted before the frontend fix). if ("GL".equalsIgnoreCase(storedAccountType)) {
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId); 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 // Link voucher to the created savings txn and stamp voucher_no on it.
? executeCurrentAccountDeposit(savingsAccountId, amount, ptId, txnDateStr, description) // parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
: executeSavingsDeposit(savingsAccountId, amount, ptId, txnDateStr, description);
// Link voucher to the created savings txn and stamp voucher_no on it
if (txnId != null) { if (txnId != null) {
voucherService.linkSavingsTransaction( final Long voucherTxnId = parseTxnIdToLong(txnId);
VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, Long.valueOf(txnId)); if (voucherTxnId != null) {
voucherService.linkSavingsTransaction(
VoucherService.REF_CASHIER_DEPOSIT_REQUEST, id, voucherTxnId);
}
} }
final String updatedLog = buildActivityLog( final String updatedLog = buildActivityLog(
@@ -369,11 +410,15 @@ public class CashierDepositRequestApiResource {
throw apiError(409, "This deposit request was already actioned by another manager."); throw apiError(409, "This deposit request was already actioned by another manager.");
} }
// Record denominations so the CASHIER's vault position reflects cash taken in. // Denomination was already saved at submit time (cashier physically received cash then).
// Officer = the cashier who physically received the cash, not the approving manager. // Now update the temporary txn_id (request id) to the real Fineract transaction id.
final String denominationData = (String) record.get("denomination_data"); // parseTxnIdToLong handles both decimal (savings/current) and hex (GL journal entry).
if (txnId != null && denominationData != null && !denominationData.isBlank()) { if (txnId != null) {
saveDepositDenomination(denominationData, record, Long.valueOf(txnId)); 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(); final JsonObject res = new JsonObject();
@@ -423,6 +468,10 @@ public class CashierDepositRequestApiResource {
throw apiError(409, "This deposit request was already actioned by another manager."); 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")); 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")); 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 BigDecimal amount = new BigDecimal(record.get("amount").toString());
final Object ptId = record.get("payment_type_id"); final Object ptId = record.get("payment_type_id");
final Object txnDateObj = record.get("txn_date"); final Object txnDateObj = record.get("txn_date");
final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null; final String txnDateStr = txnDateObj != null ? txnDateObj.toString().split(" ")[0] : null;
final String description = (String) record.get("description"); 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."); 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) { private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(""" final List<Map<String, Object>> 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, amount, payment_type_id, payment_type_name, description, cheque_no, bank_name,
txn_date, status, cashier_id, cashier_name, cashier_username, txn_date, status, cashier_id, cashier_name, cashier_username,
manager_id, manager_name, manager_username, rejection_reason, txn_id, 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<String, com.google.gson.JsonElement> 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<Long> 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 * Saves the denomination breakdown the cashier entered at submit time into
* m_savings_txn_denomination once the deposit is approved, so the cashier's * m_savings_txn_denomination once the deposit is approved, so the cashier's
* vault position report reflects the cash received at the counter. * vault position report reflects the cash received at the counter.
* denominationData format: {"note:500": 2, "coin:1": 10} (kind:value → count) * 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<String, com.google.gson.JsonElement> 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<Long> 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, private void saveDepositDenomination(final String denominationData,
final Map<?, ?> record, final Long txnId) { final Map<?, ?> record, final Long txnId) {
final JsonObject denomMap; final JsonObject denomMap;
@@ -358,4 +358,5 @@
<include file="parts/0328_add_voucher_no_to_savings_txn.xml" relativeToChangelogFile="true"/> <include file="parts/0328_add_voucher_no_to_savings_txn.xml" relativeToChangelogFile="true"/>
<include file="parts/0329_create_voucher.xml" relativeToChangelogFile="true"/> <include file="parts/0329_create_voucher.xml" relativeToChangelogFile="true"/>
<include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/> <include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0331_add_gl_columns_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
</databaseChangeLog> </databaseChangeLog>
@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<changeSet id="0331_add_gl_columns_to_cashier_deposit_request" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_cashier_deposit_request"/>
<not><columnExists tableName="m_cashier_deposit_request" columnName="gl_account_id"/></not>
</preConditions>
<addColumn tableName="m_cashier_deposit_request">
<column name="gl_account_id" type="BIGINT"/>
<column name="contra_gl_id" type="BIGINT"/>
<column name="office_id" type="BIGINT"/>
<column name="currency_code" type="VARCHAR(10)"/>
</addColumn>
</changeSet>
</databaseChangeLog>