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:
co-authored by
Claude Sonnet 4.6
parent
40449ef0f7
commit
3e1abaef03
+245
-24
@@ -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>
|
||||
|
||||
+20
@@ -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>
|
||||
Reference in New Issue
Block a user