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 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<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,
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<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
* 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<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,
final Map<?, ?> record, final Long txnId) {
final JsonObject denomMap;
@@ -358,4 +358,5 @@
<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/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0331_add_gl_columns_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
</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>