feat(clerk+gl): implement real CBS book transfer for savings and current account disbursements

- Replace Fineract cash deposit API with direct INSERT for both savings and current accounts
  → Zero CashVault entries created anywhere in DB for disbursements
- Savings: directCreditSavingsAccount now sets running_balance_derived and updates
  total_deposits_derived alongside account_balance_derived (parity with current account)
- Current: directCreditCurrentAccount — direct INSERT into m_current_account_transaction
  with running_balance_derived, total_deposits_derived update, and remarks audit field
- getCurrentControlGlId: looks up SAVINGS_CONTROL GL for current account products
  via acc_product_mapping (product_type=2, financial_account_type=2)
- directReverseCurrentCredit: compensation restores both account_balance_derived
  and total_deposits_derived on rollback
- GL: combined DR sourceGL / CR SavingsControl entry posted for both savings and
  current account credits; linkGlEntriesToSavingsAccounts sets entity_type_enum=2
  so ACCOUNT NO column shows in journal entries view
- Hide reversed=true GL entries from all GL views in JournalEntryReadPlatformServiceImpl

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-17 11:04:17 +05:30
co-authored by Claude Sonnet 4.6
parent d002fc0c9b
commit bf5ce421df
4 changed files with 631 additions and 122 deletions
@@ -264,6 +264,10 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
int arrayPos = 0;
String whereClose = " where ";
// Exclude intermediary/reversed entries from all GL views
sqlBuilder.append(whereClose).append("(journalEntry.reversed IS NULL OR journalEntry.reversed = false)");
whereClose = " and ";
if (StringUtils.isNotBlank(transactionId)) {
sqlBuilder.append(whereClose).append(" journalEntry.transaction_id = ?");
objectArray[arrayPos] = transactionId;
@@ -580,7 +584,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS ob_debit, "
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS ob_credit "
+ "FROM acc_gl_journal_entry je "
+ "WHERE je.account_id = ? AND je.entry_date < ?");
+ "WHERE je.account_id = ? AND je.entry_date < ? AND (je.reversed IS NULL OR je.reversed = false)");
obParams.add(glAccountId);
obParams.add(fromDate);
if (officeId != null && officeId > 0) {
@@ -607,7 +611,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS dw_credit, "
+ "COUNT(*) AS dw_count "
+ "FROM acc_gl_journal_entry je "
+ "WHERE 1=1");
+ "WHERE (je.reversed IS NULL OR je.reversed = false)");
if (glAccountId != null && glAccountId > 0) {
dwSql.append(" AND je.account_id = ?");
dwParams.add(glAccountId);
@@ -683,7 +687,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
final StringBuilder totSql = new StringBuilder(
"SELECT COALESCE(SUM(CASE WHEN je.type_enum = 2 THEN je.amount ELSE 0 END), 0) AS totalDebit, "
+ "COALESCE(SUM(CASE WHEN je.type_enum = 1 THEN je.amount ELSE 0 END), 0) AS totalCredit "
+ "FROM acc_gl_journal_entry je WHERE 1=1");
+ "FROM acc_gl_journal_entry je WHERE (je.reversed IS NULL OR je.reversed = false)");
if (glAccountId != null && glAccountId > 0) {
totSql.append(" AND je.account_id = ?");
totParams.add(glAccountId);
@@ -722,7 +726,7 @@ public class JournalEntryReadPlatformServiceImpl implements JournalEntryReadPlat
{
final List<Object> cntParams = new ArrayList<>();
final StringBuilder cntSql = new StringBuilder(
"SELECT COUNT(*) FROM acc_gl_journal_entry je WHERE 1=1");
"SELECT COUNT(*) FROM acc_gl_journal_entry je WHERE (je.reversed IS NULL OR je.reversed = false)");
if (glAccountId != null && glAccountId > 0) {
cntSql.append(" AND je.account_id = ?");
cntParams.add(glAccountId);
@@ -38,15 +38,22 @@ import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import java.math.BigDecimal;
import java.sql.Date;
import java.sql.PreparedStatement;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.jdbc.support.KeyHolder;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
@@ -66,6 +73,7 @@ import org.springframework.transaction.annotation.Transactional;
@Component
@Tag(name = "Clerk Disbursement Requests", description = "Maker-checker GL-to-account disbursement workflow for clerk and bank manager.")
@RequiredArgsConstructor
@Slf4j
public class ClerkDisbursementRequestApiResource {
private final JdbcTemplate jdbcTemplate;
@@ -162,7 +170,7 @@ public class ClerkDisbursementRequestApiResource {
""",
Long.class,
glAccountId, glAccountNo, glAccountName, txnAmount,
officeId != null ? officeId : 1L, currencyCode,
officeId != null ? officeId : currentUser.getOffice().getId(), currencyCode,
creditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
@@ -181,6 +189,9 @@ public class ClerkDisbursementRequestApiResource {
// ── GET /v1/clerk/disbursement-requests ── Manager sees all requests ─────────
private static final java.util.Set<String> VALID_STATUSES = java.util.Set.of(
"PENDING", "APPROVED", "REJECTED", "FAILED", "PROCESSING");
@GET
@Produces(MediaType.APPLICATION_JSON)
public String list(
@@ -190,15 +201,32 @@ public class ClerkDisbursementRequestApiResource {
@QueryParam("fromDate") final String fromDate,
@QueryParam("toDate") final String toDate) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
if (!VALID_STATUSES.contains(status)) {
throw apiError(400, "Invalid status '" + status + "'. Must be one of: " + VALID_STATUSES);
}
final int safeLimit = Math.min(limit, 200);
final StringBuilder where = new StringBuilder("WHERE status = ?");
final List<Object> baseParams = new ArrayList<>(List.of(status));
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate));
try {
where.append(" AND DATE(submitted_at) >= ?");
baseParams.add(Date.valueOf(fromDate));
} catch (IllegalArgumentException e) {
throw apiError(400, "Invalid fromDate format. Use yyyy-MM-dd.");
}
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
baseParams.add(Date.valueOf(toDate));
try {
where.append(" AND DATE(submitted_at) <= ?");
baseParams.add(Date.valueOf(toDate));
} catch (IllegalArgumentException e) {
throw apiError(400, "Invalid toDate format. Use yyyy-MM-dd.");
}
}
final long total = Optional.ofNullable(
@@ -208,7 +236,7 @@ public class ClerkDisbursementRequestApiResource {
).orElse(0L);
final List<Object> pageParams = new ArrayList<>(baseParams);
pageParams.add(limit);
pageParams.add(safeLimit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
@@ -216,7 +244,7 @@ public class ClerkDisbursementRequestApiResource {
+ " credit_entries, payment_type_name, description,"
+ " clerk_id, clerk_name, clerk_username,"
+ " manager_id, manager_name, manager_username,"
+ " rejection_reason, txn_refs, submitted_at, actioned_at"
+ " rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -240,20 +268,34 @@ public class ClerkDisbursementRequestApiResource {
@QueryParam("toDate") final String toDate) {
final AppUser currentUser = context.authenticatedUser();
final int safeLimit = Math.min(limit, 200);
final StringBuilder where = new StringBuilder("WHERE clerk_id = ?");
final List<Object> params = new ArrayList<>(List.of(currentUser.getId()));
if (status != null && !status.isBlank()) {
if (!VALID_STATUSES.contains(status)) {
throw apiError(400, "Invalid status '" + status + "'. Must be one of: " + VALID_STATUSES);
}
where.append(" AND status = ?");
params.add(status);
}
if (fromDate != null && !fromDate.isBlank()) {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
try {
where.append(" AND DATE(submitted_at) >= ?");
params.add(Date.valueOf(fromDate));
} catch (IllegalArgumentException e) {
throw apiError(400, "Invalid fromDate format. Use yyyy-MM-dd.");
}
}
if (toDate != null && !toDate.isBlank()) {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
try {
where.append(" AND DATE(submitted_at) <= ?");
params.add(Date.valueOf(toDate));
} catch (IllegalArgumentException e) {
throw apiError(400, "Invalid toDate format. Use yyyy-MM-dd.");
}
}
final long total = Optional.ofNullable(
@@ -263,13 +305,13 @@ public class ClerkDisbursementRequestApiResource {
).orElse(0L);
final List<Object> pageParams = new ArrayList<>(params);
pageParams.add(limit);
pageParams.add(safeLimit);
pageParams.add(offset);
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount,"
+ " credit_entries, payment_type_name, description, clerk_name,"
+ " manager_name, rejection_reason, txn_refs, submitted_at, actioned_at"
+ " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at"
+ " FROM m_clerk_disbursement_request " + where
+ " ORDER BY submitted_at DESC LIMIT ? OFFSET ?",
pageParams.toArray());
@@ -286,7 +328,18 @@ public class ClerkDisbursementRequestApiResource {
@Path("/{id}")
@Produces(MediaType.APPLICATION_JSON)
public String getById(@PathParam("id") final Long id) {
return gson.toJson(loadRecord(id));
final AppUser currentUser = context.authenticatedUser();
final Map<?, ?> record = loadRecord(id);
// Manager (checker role) can view any record; clerk can only view their own.
final boolean isManager = isCheckerRole(currentUser);
if (!isManager) {
final Long clerkId = record.get("clerk_id") != null
? ((Number) record.get("clerk_id")).longValue() : null;
if (!currentUser.getId().equals(clerkId)) {
throw apiError(403, "You can only view your own disbursement requests.");
}
}
return gson.toJson(record);
}
// ── POST /v1/clerk/disbursement-requests/{id}/approve ── Manager approves ─────
@@ -295,7 +348,9 @@ public class ClerkDisbursementRequestApiResource {
@Path("/{id}/approve")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Transactional
// NOT @Transactional: Fineract commands commit in their own REQUIRES_NEW transactions.
// Our DB status updates (PROCESSING → APPROVED/FAILED) must also commit independently
// so the status persists correctly even when an exception is thrown mid-processing.
public String approve(@PathParam("id") final Long id, final String body) {
final AppUser currentUser = context.authenticatedUser();
validateCheckerRole(currentUser);
@@ -323,25 +378,34 @@ public class ClerkDisbursementRequestApiResource {
final String currencyCode = record.get("currency_code") != null
? (String) record.get("currency_code") : "INR";
// Validate all entries have required IDs before posting any transaction
// Validate all entries have required IDs before acquiring the processing lock.
// Validation failures do not change status — request stays PENDING for clerk to fix.
for (int i = 0; i < creditEntries.size(); i++) {
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
if ("GL".equals(entryType)) {
if (!entry.has("glAccountId") || entry.get("glAccountId").isJsonNull()) {
throw apiError(400, "GL account ID missing for GL credit entry: " + acNo
throw apiError(400, "GL account ID missing for GL entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
}
} else {
if (!entry.has("savingsAccountId") || entry.get("savingsAccountId").isJsonNull()) {
throw apiError(400, "Account ID missing for credit entry: " + acNo
throw apiError(400, "Account ID missing for entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
}
}
}
// Detect collection mode: any DR entry in creditEntries = collection (savings debit)
// Atomic PENDING → PROCESSING lock: prevents two managers from concurrently
// posting duplicate Fineract transactions for the same request.
final int locked = jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET status = 'PROCESSING' WHERE id = ? AND status = 'PENDING'", id);
if (locked == 0) {
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)
boolean isCollectionMode = false;
for (final JsonElement el : creditEntries) {
final JsonObject e = el.getAsJsonObject();
@@ -352,122 +416,197 @@ public class ClerkDisbursementRequestApiResource {
}
final List<String> txnRefs = new ArrayList<>();
// Disbursement: savings deposits — for offset DR sourceGL / CR Cash@Vault
final List<String> depositSavingsTxnRefs = new ArrayList<>();
BigDecimal depositSavingsTotal = BigDecimal.ZERO;
// Collection: savings withdrawals — for offset DR Cash@Vault / CR targetGL
final List<String> withdrawalSavingsTxnRefs = new ArrayList<>();
BigDecimal withdrawalSavingsTotal = BigDecimal.ZERO;
Long collectionTargetGlId = null;
// Process each entry — all or nothing (@Transactional rolls back on any failure)
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);
// Savings-control GL data per account for the direct combined journal entry.
// collectionGlEntries: DR SavingsControl per savings account (collection/withdrawal mode).
// disbursementGlEntries: CR SavingsControl per savings account (disbursement/credit mode).
final List<GlEntry> collectionGlEntries = new ArrayList<>();
final List<GlEntry> disbursementGlEntries = new ArrayList<>();
// Compensation list: each successfully posted Fineract transaction gets an undo lambda.
// On partial failure, these are run in reverse order (LIFO) for clean rollback.
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 ("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 ("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 Cash@Vault offset
collectionTargetGlId = glId;
}
}
txnRefs.add("GL:" + acNo);
continue;
}
// Savings or Current account
final Long accountId = entry.get("savingsAccountId").getAsLong();
final boolean isCurrentAccount = isCurrentAccountById(accountId);
String txnId = null;
if ("Cr".equals(dbCr)) {
// Deposit — credit savings/current account (disbursement)
try {
txnId = isCurrentAccount
? executeCurrentAccountDeposit(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsDeposit(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
} catch (Exception ex) {
throw apiError(422, "Failed to credit account " + acNo + ": "
+ (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()));
}
if (txnId != null) {
txnRefs.add(txnId);
if (!isCurrentAccount) {
depositSavingsTxnRefs.add(txnId);
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));
}
} 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));
}
}
}
}
} else {
// Withdrawal — debit savings/current account (collection)
}
// ── 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)),
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);
}
}
}
} catch (Exception ex) {
// Compensate: undo all posted Fineract transactions in reverse order (best-effort).
// This restores account balances even when a later entry fails mid-processing.
for (int i = undoOps.size() - 1; i >= 0; i--) {
try {
txnId = isCurrentAccount
? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description)
: executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description);
} catch (IdempotentCommandProcessSucceedException idempotent) {
txnId = extractResourceIdFromResponse(idempotent.getResponse());
} catch (Exception ex) {
throw apiError(422, "Failed to debit account " + acNo + ": "
+ (ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName()));
}
if (txnId != null) {
txnRefs.add(txnId);
if (!isCurrentAccount) {
withdrawalSavingsTxnRefs.add(txnId);
withdrawalSavingsTotal = withdrawalSavingsTotal.add(entryAmt);
}
undoOps.get(i).run();
} catch (Exception undoEx) {
log.warn("Compensation undo op {} failed for disbursement request {}: {}",
i, id, undoEx.getMessage());
}
}
}
// Disbursement offset: deposits auto-create DR Cash@Vault / CR Savings.
// Cancel Cash@Vault side: DR sourceGL / CR Cash@Vault. Net: DR sourceGL / CR Savings.
if (sourceGlId != null && !depositSavingsTxnRefs.isEmpty()) {
final Long cashVaultGlId = findCashVaultGlId(depositSavingsTxnRefs, 2); // DEBIT side of deposit entry
if (cashVaultGlId != null) {
postGlOffsetEntry(sourceGlId, cashVaultGlId, depositSavingsTotal,
officeId, currencyCode, txnDateStr, description);
}
}
// Collection offset: withdrawals auto-create DR Savings / CR Cash@Vault.
// Cancel Cash@Vault side: DR Cash@Vault / CR targetGL. Net: DR Savings / CR targetGL.
if (collectionTargetGlId != null && !withdrawalSavingsTxnRefs.isEmpty()) {
final Long cashVaultGlId = findCashVaultGlId(withdrawalSavingsTxnRefs, 1); // CREDIT side of withdrawal entry
if (cashVaultGlId != null) {
postGlOffsetEntry(cashVaultGlId, collectionTargetGlId, withdrawalSavingsTotal,
officeId, currencyCode, txnDateStr, description);
final String errMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName();
log.error("Approval failed for disbursement request {} by {}: {}", id, currentUser.getUsername(), errMsg);
final String truncated = errMsg.length() > 1000 ? errMsg.substring(0, 1000) : errMsg;
final String failLog = buildActivityLog(
(String) record.get("activity_log"), "FAILED", currentUser.getUsername(), truncated);
try {
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET status = 'FAILED',"
+ " error_message = ?, activity_log = ?, actioned_at = ?, manager_id = ?,"
+ " manager_name = ?, manager_username = ? WHERE id = ?",
truncated, failLog, Timestamp.from(Instant.now()),
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), id);
} catch (Exception dbEx) {
log.error("Failed to persist FAILED status for disbursement request {}: {}", id, dbEx.getMessage());
}
// Return a sanitized message to the caller; full detail is in server logs and error_message field.
throw apiError(422, "Approval processing failed. All partial transactions have been reversed. Check the request for details.");
}
final String txnRefsStr = String.join(",", txnRefs);
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "APPROVED", currentUser.getUsername(), null);
final int updated = jdbcTemplate.update("""
jdbcTemplate.update("""
UPDATE m_clerk_disbursement_request SET
status = 'APPROVED', manager_id = ?, manager_name = ?, manager_username = ?,
txn_refs = ?, activity_log = ?, actioned_at = ?
WHERE id = ? AND status = 'PENDING'
WHERE id = ?
""",
currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(),
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id
);
if (updated == 0) {
throw apiError(409, "This disbursement request was already actioned by another manager.");
}
txnRefsStr, updatedLog, Timestamp.from(Instant.now()), id);
final JsonObject res = new JsonObject();
res.addProperty("resourceId", id);
@@ -488,6 +627,14 @@ public class ClerkDisbursementRequestApiResource {
validateCheckerRole(currentUser);
final Map<?, ?> record = loadRecord(id);
// Maker-checker: the person who submitted the request cannot also reject it.
final Long makerId = record.get("clerk_id") != null
? ((Number) record.get("clerk_id")).longValue() : null;
if (makerId != null && makerId.equals(currentUser.getId())) {
throw apiError(403,
"Maker-checker: you cannot reject a disbursement request you submitted yourself.");
}
if (!"PENDING".equals(record.get("status"))) {
throw apiError(409, "Only PENDING requests can be rejected. Current status: " + record.get("status"));
}
@@ -519,12 +666,15 @@ public class ClerkDisbursementRequestApiResource {
// ── Helpers ───────────────────────────────────────────────────────────────────
private void validateCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) return;
final boolean isChecker = user.getRoles().stream()
private boolean isCheckerRole(final AppUser user) {
if (user.hasAnyPermission("ALL_FUNCTIONS")) return true;
return user.getRoles().stream()
.map(r -> r.getName() == null ? "" : r.getName().toLowerCase().trim())
.anyMatch(n -> n.contains("manager") || n.contains("head cashier"));
if (!isChecker) {
}
private void validateCheckerRole(final AppUser user) {
if (!isCheckerRole(user)) {
throw apiError(403, "Only a Bank Manager (or Head Cashier) can approve or reject disbursement requests.");
}
}
@@ -550,7 +700,7 @@ public class ClerkDisbursementRequestApiResource {
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,
txn_refs, activity_log, submitted_at, actioned_at
txn_refs, error_message, activity_log, submitted_at, actioned_at
FROM m_clerk_disbursement_request WHERE id = ?
""", id);
if (rows.isEmpty()) {
@@ -559,6 +709,30 @@ public class ClerkDisbursementRequestApiResource {
return rows.get(0);
}
private void registerUndo(final List<Runnable> undoOps, final Long accountId,
final String txnId, final boolean isCurrentAccount) {
try {
final Long txnIdLong = Long.parseLong(txnId);
undoOps.add(() -> undoAccountTransaction(accountId, txnIdLong, isCurrentAccount));
} catch (NumberFormatException ignored) {}
}
private void undoAccountTransaction(final Long accountId, final Long transactionId,
final boolean isCurrentAccount) {
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
final CommandWrapper cmd = isCurrentAccount
? new CommandWrapperBuilder()
.undoCurrentAccountTransaction(accountId, transactionId)
.withJson(payload.toString()).build()
: new CommandWrapperBuilder()
.undoSavingsAccountTransaction(accountId, transactionId)
.withJson(payload.toString()).build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
commandsSourceWritePlatformService.logCommandSource(cmd);
}
private String executeSavingsDeposit(
final Long savingsAccountId, final BigDecimal amount,
final Long paymentTypeId, final String txnDateStr, final String note) {
@@ -576,8 +750,6 @@ public class ClerkDisbursementRequestApiResource {
.savingsAccountDeposit(savingsAccountId)
.withJson(payload.toString())
.build();
// Clear the stored command source ID so each deposit is treated as a fresh command,
// not a retry of the previous deposit within the same HTTP request.
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
@@ -750,4 +922,319 @@ public class ClerkDisbursementRequestApiResource {
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
commandsSourceWritePlatformService.logCommandSource(cmd);
}
// ── Combined GL entry helpers ─────────────────────────────────────────────
/** Carries savings-control GL data for one account in a combined journal entry. */
private static final class GlEntry {
final Long glAccountId;
final BigDecimal amount;
final String accountRef; // savings account number shown in GL comments; null for source/target GL
final String txnId; // full Fineract transaction_id (e.g. "S123"); null for pure GL entries
final Long savingsAccountId; // numeric savings account ID for GL entity linking; null for GL-only entries
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId) {
this(glAccountId, amount, accountRef, txnId, null);
}
GlEntry(final Long glAccountId, final BigDecimal amount,
final String accountRef, final String txnId, final Long savingsAccountId) {
this.glAccountId = glAccountId;
this.amount = amount;
this.accountRef = accountRef;
this.txnId = txnId;
this.savingsAccountId = savingsAccountId;
}
}
/**
* Finds the savings-control GL account ID from a Fineract auto-created journal entry.
* typeEnum=1 (CREDIT) → SavingsControl for deposits (DR CashVault / CR SavingsControl).
* typeEnum=2 (DEBIT) → SavingsControl for withdrawals (DR SavingsControl / CR CashVault).
*/
private Long findAccountGlFromTxn(final String fullTxnId, final int typeEnum) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
fullTxnId, typeEnum);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("account_id")).longValue();
}
/**
* Marks all Fineract auto-created journal entries for these transactions as reversed=true.
* Reversed entries are excluded from GL reports — only our direct combined entry is visible.
* Compensation: unmarkTxnGlEntriesReversed() restores them if the combined entry fails.
*/
private void markTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = true WHERE transaction_id = ?",
e.txnId);
}
}
}
/** Compensation for markTxnGlEntriesReversed — restores reversed=false so Fineract undo is clean. */
private void unmarkTxnGlEntriesReversed(final List<GlEntry> entries) {
for (final GlEntry e : entries) {
if (e.txnId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET reversed = false WHERE transaction_id = ?",
e.txnId);
}
}
}
/**
* After posting a combined journal entry, links each debit (typeEnum=2) or credit (typeEnum=1)
* row to its savings account so the GL view shows ACCOUNT NO instead of "—".
* Rows are matched to entries by insertion order (Fineract inserts in JSON array order).
*/
private void linkGlEntriesToSavingsAccounts(final String glTxnId,
final List<GlEntry> entries, final int typeEnum) {
final List<Long> rowIds = jdbcTemplate.queryForList(
"SELECT id FROM acc_gl_journal_entry WHERE transaction_id = ? AND type_enum = ? ORDER BY id ASC",
Long.class, glTxnId, typeEnum);
for (int i = 0; i < Math.min(rowIds.size(), entries.size()); i++) {
final Long savingsAccId = entries.get(i).savingsAccountId;
if (savingsAccId != null) {
jdbcTemplate.update(
"UPDATE acc_gl_journal_entry SET entity_type_enum = 2, entity_id = ? WHERE id = ?",
savingsAccId, rowIds.get(i));
}
}
}
/** Returns the Savings Control GL account ID for this savings account (from acc_product_mapping). */
private Long getSavingsControlGlId(final Long savingsAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT apm.gl_account_id FROM m_savings_account sa "
+ "JOIN acc_product_mapping apm ON apm.product_id = sa.product_id "
+ "WHERE sa.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
savingsAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/**
* Real CBS book transfer: directly credits a savings account without going through
* Fineract's cash deposit API. This avoids the CashVault intermediary GL entry entirely.
* Validates account is ACTIVE (status_enum=300), then inserts a savings transaction
* record and updates the balance. Running balance / cumulative balance fields are
* recalculated by Fineract's nightly updateRunningBalance batch job.
*/
private long directCreditSavingsAccount(final Long accountId, final BigDecimal amount,
final Long officeId, final String txnDate, final AppUser currentUser) {
final List<Map<String, Object>> accts = jdbcTemplate.queryForList(
"SELECT status_enum, account_balance_derived, total_deposits_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;
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, 1, 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.add(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_deposits_derived = total_deposits_derived + ? "
+ "WHERE id = ?",
amount, amount, accountId);
return txnId;
}
/** Compensation for directCreditSavingsAccount — marks transaction reversed and restores balance. */
private void directReverseSavingsCredit(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_deposits_derived = total_deposits_derived - ? "
+ "WHERE id = ?",
amount, amount, accountId);
}
/** Returns the Savings Control GL account ID for this current account (from acc_product_mapping). */
private Long getCurrentControlGlId(final Long currentAccountId) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT apm.gl_account_id FROM m_current_account ca "
+ "JOIN acc_product_mapping apm ON apm.product_id = ca.product_id "
+ "WHERE ca.id = ? AND apm.product_type = 2 AND apm.financial_account_type = 2 LIMIT 1",
currentAccountId);
return rows.isEmpty() ? null : ((Number) rows.get(0).get("gl_account_id")).longValue();
}
/**
* Real CBS book transfer: directly credits a current account without going through
* Fineract's cash deposit API. No CashVault entry is created anywhere in the DB.
* Validates account is ACTIVE (status_enum=300), inserts a current account transaction
* record, updates account_balance_derived and total_deposits_derived.
*/
private long directCreditCurrentAccount(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;
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 (?, ?, 1, ?, ?, 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.add(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_deposits_derived = total_deposits_derived + ? "
+ "WHERE id = ?",
amount, amount, accountId);
return txnId;
}
/** Compensation for directCreditCurrentAccount — marks transaction reversed and restores balance. */
private void directReverseCurrentCredit(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_deposits_derived = total_deposits_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
* in the overall comments so the GL entry identifies which accounts were affected.
*
* Collection: debits = [SavingsControl per account], credits = [targetGL]
* Disbursement: debits = [sourceGL], credits = [SavingsControl per account]
*/
private String postCombinedJournalEntry(
final List<GlEntry> debits, final List<GlEntry> credits,
final Long officeId, final String currencyCode,
final String txnDateStr, final String comments) {
final JsonArray debitArr = new JsonArray();
for (final GlEntry e : debits) {
final JsonObject d = new JsonObject();
d.addProperty("glAccountId", e.glAccountId);
d.addProperty("amount", e.amount);
debitArr.add(d);
}
final JsonArray creditArr = new JsonArray();
for (final GlEntry e : credits) {
final JsonObject c = new JsonObject();
c.addProperty("glAccountId", e.glAccountId);
c.addProperty("amount", e.amount);
creditArr.add(c);
}
final JsonObject payload = new JsonObject();
payload.addProperty("officeId", officeId);
payload.addProperty("transactionDate", formatForFineract(txnDateStr));
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
if (comments != null && !comments.isBlank()) {
payload.addProperty("comments", comments);
}
payload.add("debits", debitArr);
payload.add("credits", creditArr);
final CommandWrapper cmd = new CommandWrapperBuilder()
.createJournalEntry()
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result != null ? result.getTransactionId() : null;
}
/** Builds the GL entry comments string listing all affected account numbers. */
private String buildGlEntryComment(final String mode, final List<GlEntry> entries,
final String description) {
final StringBuilder sb = new StringBuilder(mode).append(":");
for (final GlEntry e : entries) {
if (e.accountRef != null) {
sb.append(" A/C ").append(e.accountRef).append(" ₹").append(e.amount).append(";");
}
}
if (description != null && !description.isBlank()) {
sb.append(" ").append(description);
}
return sb.toString();
}
}
@@ -361,4 +361,5 @@
<include file="parts/0331_add_gl_columns_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
<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"/>
</databaseChangeLog>
@@ -0,0 +1,17 @@
<?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="0334_clerk_disbursement_add_error_message" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_disbursement_request"/>
<not><columnExists tableName="m_clerk_disbursement_request" columnName="error_message"/></not>
</preConditions>
<addColumn tableName="m_clerk_disbursement_request">
<column name="error_message" type="VARCHAR(1000)"/>
</addColumn>
</changeSet>
</databaseChangeLog>