feat(bank-transfer): add maker-checker support for bank transfers

- Added request_type column to m_clerk_disbursement_request via migration 0336
- submit() extracts requestType from body, stores in DB (defaults to DISBURSEMENT)
- approve() detects BANK_TRANSFER mode via request_type field:
    - DR entries: directDebitSavingsAccount / directDebitCurrentAccount
    - CR entries: directCreditSavingsAccount / directCreditCurrentAccount
    - GL: DR SavingsControl(per DR) / CR SavingsControl(per CR)
    - Audit INSERT into m_bank_transfer_log with unique BT voucher number
    - Existing disbursement/collection logic unchanged in else branch
- list(), myRequests(), loadRecord() all return request_type
- Migration 0335 (m_bank_transfer_log) also included

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-17 14:47:21 +05:30
co-authored by Claude Sonnet 4.6
parent bf5ce421df
commit 7354da8c1e
4 changed files with 664 additions and 129 deletions
@@ -45,10 +45,12 @@ import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
@@ -104,6 +106,11 @@ public class ClerkDisbursementRequestApiResource {
final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root);
final String description = fromJsonHelper.extractStringNamed("description", root);
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
String requestType = fromJsonHelper.extractStringNamed("requestType", root);
if (requestType == null || requestType.isBlank()
|| !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) {
requestType = "DISBURSEMENT";
}
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
throw apiError(400, "At least one entry is required");
@@ -164,8 +171,8 @@ public class ClerkDisbursementRequestApiResource {
INSERT INTO m_clerk_disbursement_request
(gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code,
credit_entries, payment_type_id, payment_type_name, description,
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
RETURNING id
""",
Long.class,
@@ -174,7 +181,7 @@ public class ClerkDisbursementRequestApiResource {
creditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
activityLog, Timestamp.from(Instant.now())
activityLog, Timestamp.from(Instant.now()), requestType
);
final String voucherNo = voucherService.createVoucher(
@@ -240,7 +247,7 @@ public class ClerkDisbursementRequestApiResource {
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
"SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount,"
+ " credit_entries, payment_type_name, description,"
+ " clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username,"
@@ -309,7 +316,7 @@ public class ClerkDisbursementRequestApiResource {
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
"SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount,"
+ " credit_entries, payment_type_name, description, clerk_name,"
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request " + where
@@ -405,13 +412,21 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(409, "This request is already being processed or has already been actioned.");
}
// Detect collection mode: any DR entry = collection (savings debits → GL credit)
// Determine request type: BANK_TRANSFER uses direct debit/credit on both sides (no GL).
final String requestType = record.get("request_type") != null
? (String) record.get("request_type") : "DISBURSEMENT";
final boolean isBankTransferMode = "BANK_TRANSFER".equals(requestType);
// Detect collection mode: any DR entry = collection (savings debits → GL credit).
// Not applicable for bank transfer (it has both DR and CR savings entries).
boolean isCollectionMode = false;
for (final JsonElement el : creditEntries) {
final JsonObject e = el.getAsJsonObject();
if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) {
isCollectionMode = true;
break;
if (!isBankTransferMode) {
for (final JsonElement el : creditEntries) {
final JsonObject e = el.getAsJsonObject();
if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) {
isCollectionMode = true;
break;
}
}
}
@@ -432,139 +447,235 @@ public class ClerkDisbursementRequestApiResource {
final List<Runnable> undoOps = new ArrayList<>();
try {
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
? entry.get("paymentTypeId").getAsLong()
: (ptId != null ? ((Number) ptId).longValue() : null);
if (isBankTransferMode) {
// ── Bank Transfer: DR savings/CA → CR savings/CA, no external GL ────
// Execute direct debit on each DR account and direct credit on each CR account.
// GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account).
final List<GlEntry> btDebitGlEntries = new ArrayList<>();
final List<GlEntry> btCreditGlEntries = new ArrayList<>();
BigDecimal btTotalDr = BigDecimal.ZERO;
if ("GL".equals(entryType)) {
final Long glId = entry.get("glAccountId").getAsLong();
if ("Cr".equals(dbCr)) {
if (!isCollectionMode && sourceGlId != null) {
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
} else {
// Collection: CR GL is the target for the combined savings debit entry
collectionTargetGlId = glId;
}
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrent = "CURRENT".equals(
entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS");
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
if (ctrlGl == null) throw new RuntimeException(
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
if ("Dr".equals(dbCr)) {
final long txnId = isCurrent
? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
btTotalDr = btTotalDr.add(entryAmt);
if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId));
} else {
final long txnId = isCurrent
? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId));
if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId));
else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId));
}
txnRefs.add("GL:" + acNo);
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrentAccount = isCurrentAccountById(accountId);
String txnId = null;
// Combined GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account)
if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) {
final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description)
+ " → " + buildGlEntryComment("", btCreditGlEntries, null);
final String btGlTxnId = postCombinedJournalEntry(
btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment);
if (btGlTxnId != null) {
linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2);
linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1);
}
if ("Cr".equals(dbCr)) {
if (isCurrentAccount) {
// Real CBS book transfer for current accounts: direct INSERT into
// m_current_account_transaction + balance update. No Fineract cash
// deposit API → no CashVault entry created anywhere in the DB.
final Long caCtrlGl = getCurrentControlGlId(accountId);
if (caCtrlGl == null) {
throw new RuntimeException(
"No savings-control GL mapping found for current account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
// Audit log in m_bank_transfer_log
final String btTxnRefsStr = String.join(",", txnRefs);
final BigDecimal finalBtTotal = btTotalDr;
final String finalBtGlTxnId = btGlTxnId;
final JsonArray btDrArr = new JsonArray();
final JsonArray btCrArr = new JsonArray();
for (final JsonElement el : creditEntries) {
final JsonObject e = el.getAsJsonObject();
if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) btDrArr.add(e);
else btCrArr.add(e);
}
final KeyHolder btLogKh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_bank_transfer_log "
+ "(voucher_no, office_id, currency_code, total_amount, description, "
+ "debit_entries, credit_entries, txn_refs, gl_txn_id, "
+ "teller_id, teller_name, teller_username, status, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'COMPLETED', CURRENT_TIMESTAMP)",
new String[]{"id"});
ps.setString(1, "BT-TMP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
ps.setLong(2, officeId);
ps.setString(3, currencyCode);
ps.setBigDecimal(4, finalBtTotal);
ps.setString(5, description);
ps.setString(6, gson.toJson(btDrArr));
ps.setString(7, gson.toJson(btCrArr));
ps.setString(8, btTxnRefsStr);
ps.setString(9, finalBtGlTxnId);
ps.setLong(10, currentUser.getId());
ps.setString(11, currentUser.getDisplayName());
ps.setString(12, currentUser.getUsername());
return ps;
}, btLogKh);
final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue();
final String btVoucherNo = generateBtVoucherNo(btLogId);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId);
}
} else {
// ── Disbursement / Collection mode ────────────────────────────────
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
final BigDecimal entryAmt = new BigDecimal(entry.get("amount").getAsString());
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?";
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
? entry.get("paymentTypeId").getAsLong()
: (ptId != null ? ((Number) ptId).longValue() : null);
if ("GL".equals(entryType)) {
final Long glId = entry.get("glAccountId").getAsLong();
if ("Cr".equals(dbCr)) {
if (!isCollectionMode && sourceGlId != null) {
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
} else {
// Collection: CR GL is the target for the combined savings debit entry
collectionTargetGlId = glId;
}
}
txnRefs.add("GL:" + acNo);
continue;
}
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrentAccount = isCurrentAccountById(accountId);
String txnId = null;
if ("Cr".equals(dbCr)) {
if (isCurrentAccount) {
// Real CBS book transfer for current accounts: direct INSERT into
// m_current_account_transaction + balance update. No Fineract cash
// deposit API → no CashVault entry created anywhere in the DB.
final Long caCtrlGl = getCurrentControlGlId(accountId);
if (caCtrlGl == null) {
throw new RuntimeException(
"No savings-control GL mapping found for current account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
}
final long directCaTxnId = directCreditCurrentAccount(
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else {
// Real CBS book transfer: direct credit to savings account.
// No Fineract cash deposit API → no CashVault entry created anywhere.
final Long savingsCtrlGl = getSavingsControlGlId(accountId);
if (savingsCtrlGl == null) {
throw new RuntimeException(
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
}
final long directTxnId = directCreditSavingsAccount(
accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
final long directCaTxnId = directCreditCurrentAccount(
accountId, entryAmt, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(directCaTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(caCtrlGl, entryAmt, acNo, null, accountId));
undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, directCaTxnId));
} else {
// Real CBS book transfer: direct credit to savings account.
// No Fineract cash deposit API → no CashVault entry created anywhere.
final Long savingsCtrlGl = getSavingsControlGlId(accountId);
if (savingsCtrlGl == null) {
throw new RuntimeException(
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
try {
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
}
final long directTxnId = directCreditSavingsAccount(
accountId, entryAmt, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(directTxnId));
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
disbursementGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, null, accountId));
undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, directTxnId));
}
} else {
try {
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
}
if (txnId != null) {
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
txnRefs.add(txnId);
if (!isCurrentAccount) {
final String fullTxnId = "S" + txnId;
withdrawalSavingsTxnRefs.add(txnId);
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
// post a direct DR SavingsControl / CR targetGL combined entry.
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
if (savingsCtrlGl != null) {
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
if (txnId != null) {
registerUndo(undoOps, accountId, txnId, isCurrentAccount);
txnRefs.add(txnId);
if (!isCurrentAccount) {
final String fullTxnId = "S" + txnId;
withdrawalSavingsTxnRefs.add(txnId);
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
// Savings withdrawal auto-creates: DR SavingsControl / CR CashVault.
// Collect the SavingsControl GL (DR side, type_enum=2) so we can
// post a direct DR SavingsControl / CR targetGL combined entry.
final Long savingsCtrlGl = findAccountGlFromTxn(fullTxnId, 2);
if (savingsCtrlGl != null) {
collectionGlEntries.add(new GlEntry(savingsCtrlGl, entryAmt, acNo, fullTxnId, accountId));
}
}
}
}
}
}
// ── Disbursement GL entry ─────────────────────────────────────────────
// Real CBS book transfer: DR sourceGL / CR SavingsControl(per account).
// Both savings and current accounts use direct INSERT — no Fineract cash
// API involved → no CashVault entry created anywhere in the DB.
if (sourceGlId != null && !disbursementGlEntries.isEmpty()) {
final String disbGlTxnId = postCombinedJournalEntry(
List.of(new GlEntry(sourceGlId, depositSavingsTotal, null, null)),
disbursementGlEntries,
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Disbursement", disbursementGlEntries, description));
// Link each CR entry to its savings/current account so ACCOUNT NO shows in the GL view.
if (disbGlTxnId != null) {
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
}
}
// ── Collection GL entry ───────────────────────────────────────────────
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
// We mark those auto-entries reversed (hidden from GL reports) and post
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
if (!collectionGlEntries.isEmpty()
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
markTxnGlEntriesReversed(collectionGlEntries);
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries,
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
// ── Disbursement GL entry ─────────────────────────────────────────────
// Real CBS book transfer: DR sourceGL / CR SavingsControl(per account).
// Both savings and current accounts use direct INSERT — no Fineract cash
// API involved → no CashVault entry created anywhere in the DB.
if (sourceGlId != null && !disbursementGlEntries.isEmpty()) {
final String disbGlTxnId = postCombinedJournalEntry(
List.of(new GlEntry(sourceGlId, depositSavingsTotal, null, null)),
disbursementGlEntries,
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
buildGlEntryComment("Disbursement", disbursementGlEntries, description));
// Link each CR entry to its savings/current account so ACCOUNT NO shows in the GL view.
if (disbGlTxnId != null) {
linkGlEntriesToSavingsAccounts(disbGlTxnId, disbursementGlEntries, 1);
}
}
}
// ── Collection GL entry ───────────────────────────────────────────────
// Goal: DR SavingsControl(per account) / CR targetGL — one combined entry.
// Fineract withdrawals auto-create: DR SavingsControl / CR CashVault.
// We mark those auto-entries reversed (hidden from GL reports) and post
// the direct combined entry. Fallback: CashVault bridge if GL lookup fails.
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
if (!collectionGlEntries.isEmpty()
&& collectionGlEntries.size() == withdrawalSavingsTxnRefs.size()) {
markTxnGlEntriesReversed(collectionGlEntries);
final List<GlEntry> snapC = new ArrayList<>(collectionGlEntries);
undoOps.add(() -> unmarkTxnGlEntriesReversed(snapC));
final String collGlTxnId = postCombinedJournalEntry(
collectionGlEntries,
List.of(new GlEntry(collectionTargetGlId, withdrawalSavingsTotal, null, null)),
officeId, currencyCode, txnDateStr,
buildGlEntryComment("Collection", collectionGlEntries, description));
// Link each DR entry to its savings account so ACCOUNT NO shows in the GL view.
if (collGlTxnId != null) {
linkGlEntriesToSavingsAccounts(collGlTxnId, collectionGlEntries, 2);
}
} else {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1);
if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
}
}
}
} // end else (disbursement/collection mode)
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
@@ -664,6 +775,227 @@ public class ClerkDisbursementRequestApiResource {
return gson.toJson(Map.of("resourceId", id, "status", "REJECTED"));
}
// ── POST /v1/clerk/disbursement-requests/banktransfer ── Intra-bank transfer ──
/**
* Real CBS intra-bank book transfer: debits N savings/current accounts and credits
* N savings/current accounts in one atomic transaction. No GL source/target from user —
* GL is auto-determined (SavingsControl per account product). Immediate execution
* (no maker-checker) — teller authority level.
*
* GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account).
* If both accounts share the same product, net GL impact = zero (same control account).
*/
@POST
@Path("/banktransfer")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
public String bankTransfer(final String body) {
final AppUser currentUser = context.authenticatedUser();
final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}");
final JsonObject req = root.getAsJsonObject();
final Long officeId = req.has("officeId") && !req.get("officeId").isJsonNull()
? req.get("officeId").getAsLong() : 1L;
final String currencyCode = req.has("currencyCode") && !req.get("currencyCode").isJsonNull()
? req.get("currencyCode").getAsString() : "INR";
final String txnDateStr = req.has("transactionDate") && !req.get("transactionDate").isJsonNull()
? req.get("transactionDate").getAsString()
: LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE);
final String description = req.has("description") && !req.get("description").isJsonNull()
? req.get("description").getAsString() : null;
final JsonArray entries = req.has("entries") ? req.getAsJsonArray("entries") : new JsonArray();
final List<JsonObject> drEntries = new ArrayList<>();
final List<JsonObject> crEntries = new ArrayList<>();
for (final JsonElement el : entries) {
final JsonObject e = el.getAsJsonObject();
final String dbCr = e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr";
if ("Dr".equals(dbCr)) drEntries.add(e);
else crEntries.add(e);
}
if (drEntries.isEmpty()) throw apiError(400, "At least one DR (debit) account entry is required.");
if (crEntries.isEmpty()) throw apiError(400, "At least one CR (credit) account entry is required.");
// Validate all entries: savings/current only (no GL), savingsAccountId present, amount > 0
final Set<Long> drAccountIds = new LinkedHashSet<>();
final Set<Long> crAccountIds = new LinkedHashSet<>();
for (final JsonObject e : drEntries) {
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(acTyp)) throw apiError(400, "GL accounts are not allowed in bank transfer. Use Disbursement/Collection instead.");
if (!e.has("savingsAccountId") || e.get("savingsAccountId").isJsonNull())
throw apiError(400, "savingsAccountId is required for all entries. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
final BigDecimal amt = new BigDecimal(e.get("amount").getAsString());
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
drAccountIds.add(e.get("savingsAccountId").getAsLong());
}
for (final JsonObject e : crEntries) {
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
if ("GL".equals(acTyp)) throw apiError(400, "GL accounts are not allowed in bank transfer. Use Disbursement/Collection instead.");
if (!e.has("savingsAccountId") || e.get("savingsAccountId").isJsonNull())
throw apiError(400, "savingsAccountId is required for all entries. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
final BigDecimal amt = new BigDecimal(e.get("amount").getAsString());
if (amt.compareTo(BigDecimal.ZERO) <= 0)
throw apiError(400, "Amount must be greater than zero. Account: "
+ (e.has("accountNo") ? e.get("accountNo").getAsString() : "?"));
final Long crId = e.get("savingsAccountId").getAsLong();
if (drAccountIds.contains(crId)) throw apiError(400,
"Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId)
+ " cannot appear on both DR and CR sides of the same transfer.");
crAccountIds.add(crId);
}
// Validate balanced entry: total DR == total CR
BigDecimal totalDr = BigDecimal.ZERO;
BigDecimal totalCr = BigDecimal.ZERO;
for (final JsonObject e : drEntries) totalDr = totalDr.add(new BigDecimal(e.get("amount").getAsString()));
for (final JsonObject e : crEntries) totalCr = totalCr.add(new BigDecimal(e.get("amount").getAsString()));
if (totalDr.compareTo(totalCr) != 0) {
throw apiError(400, "Unbalanced entry: DR total (" + totalDr + ") ≠ CR total (" + totalCr + "). Bank transfer must balance.");
}
final List<Runnable> undoOps = new ArrayList<>();
final List<String> txnRefs = new ArrayList<>();
final List<GlEntry> debitGlEntries = new ArrayList<>();
final List<GlEntry> creditGlEntries = new ArrayList<>();
String glTxnId = null;
try {
// ── Process DR entries (debit accounts) ───────────────────────────────
for (final JsonObject e : drEntries) {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
if (ctrlGl == null) throw new RuntimeException(
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
final long txnId = isCurrent
? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description);
txnRefs.add(String.valueOf(txnId));
debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId));
} else {
undoOps.add(() -> directReverseSavingsDebit(accountId, amount, txnId));
}
}
// ── Process CR entries (credit accounts) ─────────────────────────────
for (final JsonObject e : crEntries) {
final Long accountId = e.get("savingsAccountId").getAsLong();
final BigDecimal amount = new BigDecimal(e.get("amount").getAsString());
final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?";
final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS";
final boolean isCurrent = "CURRENT".equals(acTyp);
final Long ctrlGl = isCurrent
? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId);
if (ctrlGl == null) throw new RuntimeException(
"No savings-control GL mapping found for account " + acNo
+ ". Check acc_product_mapping (product_type=2, financial_account_type=2).");
final long txnId = isCurrent
? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description)
: directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser);
txnRefs.add(String.valueOf(txnId));
creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId));
if (isCurrent) {
undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId));
} else {
undoOps.add(() -> directReverseSavingsCredit(accountId, amount, txnId));
}
}
// ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─
final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description)
+ " → " + buildGlEntryComment("", creditGlEntries, null);
glTxnId = postCombinedJournalEntry(
debitGlEntries, creditGlEntries,
officeId, currencyCode, txnDateStr, btComment);
if (glTxnId != null) {
linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2);
linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1);
}
} catch (Exception ex) {
for (int i = undoOps.size() - 1; i >= 0; i--) {
try { undoOps.get(i).run(); } catch (Exception undoEx) {
log.warn("Bank transfer undo op {} failed: {}", i, undoEx.getMessage());
}
}
final String errMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName();
log.error("Bank transfer failed by {}: {}", currentUser.getUsername(), errMsg);
throw apiError(422, "Bank transfer failed and has been reversed: " + errMsg);
}
// ── Audit log ─────────────────────────────────────────────────────────────
// Insert first with a placeholder voucher_no, then update with the generated ID
// so the voucher number is guaranteed unique (ID comes from DB sequence).
final String txnRefsStr = String.join(",", txnRefs);
final BigDecimal finalTotalDr = totalDr; // effectively-final copy for lambda
final String finalGlTxnId = glTxnId; // effectively-final copy for lambda
final KeyHolder logKh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_bank_transfer_log "
+ "(voucher_no, office_id, currency_code, total_amount, description, "
+ "debit_entries, credit_entries, txn_refs, gl_txn_id, "
+ "teller_id, teller_name, teller_username, status, created_at) "
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'COMPLETED', CURRENT_TIMESTAMP)",
new String[]{"id"});
ps.setString(1, "BT-TMP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
ps.setLong(2, officeId);
ps.setString(3, currencyCode);
ps.setBigDecimal(4, finalTotalDr);
ps.setString(5, description);
ps.setString(6, gson.toJson(drEntries));
ps.setString(7, gson.toJson(crEntries));
ps.setString(8, txnRefsStr);
ps.setString(9, finalGlTxnId);
ps.setLong(10, currentUser.getId());
ps.setString(11, currentUser.getDisplayName());
ps.setString(12, currentUser.getUsername());
return ps;
}, logKh);
final long logId = Objects.requireNonNull(logKh.getKey()).longValue();
final String voucherNo = generateBtVoucherNo(logId);
jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId);
final JsonObject res = new JsonObject();
res.addProperty("voucherNo", voucherNo);
res.addProperty("txnRefs", txnRefsStr);
res.addProperty("totalAmount", totalDr);
res.addProperty("status", "COMPLETED");
return gson.toJson(res);
}
/**
* Generates a unique bank-transfer voucher number: BT + yyMMdd + 5-digit sequence.
* Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even
* under concurrent inserts on the same date.
*/
private String generateBtVoucherNo(final long logId) {
return "BT" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd"))
+ String.format("%05d", logId);
}
// ── Helpers ───────────────────────────────────────────────────────────────────
private boolean isCheckerRole(final AppUser user) {
@@ -696,7 +1028,7 @@ public class ClerkDisbursementRequestApiResource {
private Map<?, ?> loadRecord(final Long id) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList("""
SELECT id, voucher_no, status, gl_account_id, gl_account_no, gl_account_name, dr_amount,
SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount,
office_id, currency_code, credit_entries, payment_type_id, payment_type_name,
description, clerk_id, clerk_name, clerk_username,
manager_id, manager_name, manager_username, rejection_reason,
@@ -1174,6 +1506,144 @@ public class ClerkDisbursementRequestApiResource {
amount, amount, accountId);
}
/**
* Real CBS book transfer — direct DEBIT (withdrawal) from a savings account.
* INSERTs a withdrawal transaction (transaction_type_enum=2) into m_savings_account_transaction
* and subtracts from account_balance_derived. Validates balance ≥ amount before proceeding.
*/
private long directDebitSavingsAccount(final Long accountId, final BigDecimal amount,
final Long officeId, final String txnDate, final AppUser currentUser, final String note) {
final List<Map<String, Object>> accts = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived FROM m_savings_account WHERE id = ?",
accountId);
if (accts.isEmpty()) throw new RuntimeException("Savings account " + accountId + " not found.");
final int statusEnum = ((Number) accts.get(0).get("status_enum")).intValue();
if (statusEnum != 300) throw new RuntimeException(
"Savings account " + accountId + " is not active (status=" + statusEnum + ").");
final BigDecimal currentBalance = accts.get(0).get("account_balance_derived") != null
? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO;
if (currentBalance.compareTo(amount) < 0) throw new RuntimeException(
"Insufficient balance in savings account " + accountId
+ ". Available: " + currentBalance + ", Requested: " + amount);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final OffsetDateTime now = OffsetDateTime.now();
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final Long userId = currentUser.getId();
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_savings_account_transaction "
+ "(savings_account_id, office_id, payment_detail_id, transaction_type_enum, "
+ "is_reversed, transaction_date, amount, submitted_on_date, ref_no, "
+ "running_balance_derived, is_lien_transaction, "
+ "created_by, last_modified_by, created_on_utc, last_modified_on_utc) "
+ "VALUES (?, ?, NULL, 2, false, ?, ?, ?, ?, ?, false, ?, ?, ?, ?)",
new String[]{"id"});
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setDate(5, sqlDate);
ps.setString(6, refNo);
ps.setBigDecimal(7, currentBalance.subtract(amount));
ps.setLong(8, userId);
ps.setLong(9, userId);
ps.setObject(10, now);
ps.setObject(11, now);
return ps;
}, kh);
final long txnId = Objects.requireNonNull(kh.getKey()).longValue();
jdbcTemplate.update(
"UPDATE m_savings_account SET "
+ "account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = total_withdrawals_derived + ? "
+ "WHERE id = ?",
amount, amount, accountId);
return txnId;
}
/** Compensation for directDebitSavingsAccount — marks transaction reversed and restores balance. */
private void directReverseSavingsDebit(final Long accountId, final BigDecimal amount, final long txnId) {
jdbcTemplate.update("UPDATE m_savings_account_transaction SET is_reversed = true WHERE id = ?", txnId);
jdbcTemplate.update(
"UPDATE m_savings_account SET "
+ "account_balance_derived = account_balance_derived + ?, "
+ "total_withdrawals_derived = total_withdrawals_derived - ? "
+ "WHERE id = ?",
amount, amount, accountId);
}
/**
* Real CBS book transfer — direct DEBIT (withdrawal) from a current account.
* INSERTs a withdrawal transaction (transaction_type_enum=2) into m_current_account_transaction
* and subtracts from account_balance_derived. Validates balance ≥ amount before proceeding.
*/
private long directDebitCurrentAccount(final Long accountId, final BigDecimal amount,
final Long officeId, final String txnDate, final AppUser currentUser, final String note) {
final List<Map<String, Object>> accts = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived FROM m_current_account WHERE id = ?", accountId);
if (accts.isEmpty()) throw new RuntimeException("Current account " + accountId + " not found.");
final int statusEnum = ((Number) accts.get(0).get("status_enum")).intValue();
if (statusEnum != 300) throw new RuntimeException(
"Current account " + accountId + " is not active (status=" + statusEnum + ").");
final BigDecimal currentBalance = accts.get(0).get("account_balance_derived") != null
? (BigDecimal) accts.get(0).get("account_balance_derived") : BigDecimal.ZERO;
if (currentBalance.compareTo(amount) < 0) throw new RuntimeException(
"Insufficient balance in current account " + accountId
+ ". Available: " + currentBalance + ", Requested: " + amount);
final java.sql.Date sqlDate = java.sql.Date.valueOf(txnDate);
final String refNo = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
final Long userId = currentUser.getId();
final String remarks = note != null && !note.isBlank()
? (note.length() > 490 ? note.substring(0, 490) : note) + " [GL book transfer]"
: "GL book transfer";
final KeyHolder kh = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
final PreparedStatement ps = con.prepareStatement(
"INSERT INTO m_current_account_transaction "
+ "(current_account_id, office_id, transaction_type_enum, transaction_date, amount, "
+ "is_reversed, is_reversal, is_manual, is_lien_transaction, "
+ "ref_no, submitted_on_date, running_balance_derived, remarks, created_date, created_by) "
+ "VALUES (?, ?, 2, ?, ?, false, false, false, false, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?)",
new String[]{"id"});
ps.setLong(1, accountId);
ps.setLong(2, officeId);
ps.setDate(3, sqlDate);
ps.setBigDecimal(4, amount);
ps.setString(5, refNo);
ps.setDate(6, sqlDate);
ps.setBigDecimal(7, currentBalance.subtract(amount));
ps.setString(8, remarks);
ps.setLong(9, userId);
return ps;
}, kh);
final long txnId = Objects.requireNonNull(kh.getKey()).longValue();
jdbcTemplate.update(
"UPDATE m_current_account SET "
+ "account_balance_derived = account_balance_derived - ?, "
+ "total_withdrawals_derived = total_withdrawals_derived + ? "
+ "WHERE id = ?",
amount, amount, accountId);
return txnId;
}
/** Compensation for directDebitCurrentAccount — marks transaction reversed and restores balance. */
private void directReverseCurrentDebit(final Long accountId, final BigDecimal amount, final long txnId) {
jdbcTemplate.update("UPDATE m_current_account_transaction SET is_reversed = true WHERE id = ?", txnId);
jdbcTemplate.update(
"UPDATE m_current_account SET "
+ "account_balance_derived = account_balance_derived + ?, "
+ "total_withdrawals_derived = total_withdrawals_derived - ? "
+ "WHERE id = ?",
amount, amount, accountId);
}
/**
* Posts ONE combined journal entry with multiple debits and credits via Fineract's
* createJournalEntry API. Each debit/credit carries the savings account reference
@@ -362,4 +362,6 @@
<include file="parts/0332_create_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0333_rename_disbursed_to_withdrawn.xml" relativeToChangelogFile="true"/>
<include file="parts/0334_clerk_disbursement_add_error_message.xml" relativeToChangelogFile="true"/>
<include file="parts/0335_create_bank_transfer_log.xml" relativeToChangelogFile="true"/>
<include file="parts/0336_add_request_type_to_clerk_disbursement.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,45 @@
<?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="0335_create_bank_transfer_log" author="CBS">
<preConditions onFail="MARK_RAN">
<not><tableExists tableName="m_bank_transfer_log"/></not>
</preConditions>
<createTable tableName="m_bank_transfer_log">
<column name="id" type="BIGSERIAL" autoIncrement="true">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="voucher_no" type="VARCHAR(25)">
<constraints unique="true" nullable="false"/>
</column>
<column name="office_id" type="BIGINT">
<constraints nullable="false"/>
</column>
<column name="currency_code" type="VARCHAR(3)" defaultValue="INR">
<constraints nullable="false"/>
</column>
<column name="total_amount" type="DECIMAL(19,6)">
<constraints nullable="false"/>
</column>
<column name="description" type="VARCHAR(500)"/>
<column name="debit_entries" type="TEXT"/>
<column name="credit_entries" type="TEXT"/>
<column name="txn_refs" type="TEXT"/>
<column name="gl_txn_id" type="VARCHAR(100)"/>
<column name="teller_id" type="BIGINT"/>
<column name="teller_name" type="VARCHAR(150)"/>
<column name="teller_username" type="VARCHAR(100)"/>
<column name="status" type="VARCHAR(20)" defaultValue="COMPLETED">
<constraints nullable="false"/>
</column>
<column name="error_message" type="VARCHAR(1000)"/>
<column name="created_at" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,18 @@
<?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="0336_add_request_type_to_clerk_disbursement" author="CBS">
<preConditions onFail="MARK_RAN">
<not><columnExists tableName="m_clerk_disbursement_request" columnName="request_type"/></not>
</preConditions>
<addColumn tableName="m_clerk_disbursement_request">
<column name="request_type" type="VARCHAR(30)" defaultValue="DISBURSEMENT">
<constraints nullable="false"/>
</column>
</addColumn>
</changeSet>
</databaseChangeLog>