feat(clerk): add collection flow backend — savings withdrawal + Cash@Vault GL offset

- ClerkDisbursementRequestApiResource: support collection mode (dbCr='Dr' entries); submit() separates DR/CR totals and validates balance; approve() detects mode, calls executeSavingsWithdrawal/executeCurrentAccountWithdrawal for DR entries, then posts GL offset via findCashVaultGlId(txnRefs, type_enum=1) → target GL
- ClerkWithdrawalRequestApiResource: minor updates
- DB migration: changelog-tenant.xml + 0333_rename_disbursed_to_withdrawn.xml

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Jaanu423
2026-06-16 15:27:32 +05:30
co-authored by Claude Sonnet 4.6
parent a6eae3d6e8
commit d002fc0c9b
4 changed files with 245 additions and 68 deletions
@@ -50,7 +50,10 @@ import lombok.RequiredArgsConstructor;
import org.apache.fineract.commands.domain.CommandWrapper;
import org.apache.fineract.commands.service.CommandWrapperBuilder;
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
import org.apache.fineract.commands.service.SynchronousCommandProcessingService;
import org.apache.fineract.infrastructure.core.data.CommandProcessingResult;
import org.apache.fineract.infrastructure.core.domain.FineractRequestContextHolder;
import org.apache.fineract.infrastructure.core.exception.IdempotentCommandProcessSucceedException;
import org.apache.fineract.infrastructure.core.serialization.FromJsonHelper;
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
import org.apache.fineract.portfolio.voucher.service.VoucherService;
@@ -69,6 +72,7 @@ public class ClerkDisbursementRequestApiResource {
private final FromJsonHelper fromJsonHelper;
private final PlatformSecurityContext context;
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
private final FineractRequestContextHolder fineractRequestContextHolder;
private final VoucherService voucherService;
private final Gson gson = new GsonBuilder().serializeNulls().create();
@@ -93,14 +97,11 @@ public class ClerkDisbursementRequestApiResource {
final String description = fromJsonHelper.extractStringNamed("description", root);
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root);
if (glAccountNo == null || glAccountNo.isBlank()) {
throw apiError(400, "GL account number (DR side) is required");
}
if (creditEntriesJson == null || creditEntriesJson.isBlank()) {
throw apiError(400, "At least one credit entry (CR side) is required");
throw apiError(400, "At least one entry is required");
}
// Parse and validate credit entries
// Parse and validate entries
final JsonArray creditEntries;
try {
creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
@@ -108,25 +109,45 @@ public class ClerkDisbursementRequestApiResource {
throw apiError(400, "creditEntries is not valid JSON");
}
if (creditEntries == null || creditEntries.size() == 0) {
throw apiError(400, "At least one credit entry (CR side) is required");
throw apiError(400, "At least one entry is required");
}
BigDecimal totalCr = BigDecimal.ZERO;
BigDecimal totalDr = BigDecimal.ZERO;
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final String dbCr = entry.has("dbCr") ? entry.get("dbCr").getAsString() : "Cr";
final String amt = entry.has("amount") ? entry.get("amount").getAsString() : null;
final BigDecimal entryAmt = parseBigDecimal(amt);
if (entryAmt == null || entryAmt.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Each credit entry must have a positive amount");
throw apiError(400, "Each entry must have a positive amount");
}
if (!entry.has("accountNo") || entry.get("accountNo").getAsString().isBlank()) {
throw apiError(400, "Each credit entry must have an account number");
throw apiError(400, "Each entry must have an account number");
}
if ("Dr".equals(dbCr)) {
totalDr = totalDr.add(entryAmt);
} else {
totalCr = totalCr.add(entryAmt);
}
totalCr = totalCr.add(entryAmt);
}
if (totalCr.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Total credit amount must be greater than zero");
// Validate balance when both DR and CR entries present (collection mode)
if (totalDr.compareTo(BigDecimal.ZERO) > 0 && totalCr.compareTo(BigDecimal.ZERO) > 0
&& totalDr.subtract(totalCr).abs().compareTo(new BigDecimal("0.01")) > 0) {
throw apiError(400, "DR entries total (" + totalDr + ") must equal CR entries total (" + totalCr + ")");
}
// Transaction amount = CR side for disbursement; either side for collection (they are equal)
final BigDecimal txnAmount = totalCr.compareTo(BigDecimal.ZERO) > 0 ? totalCr : totalDr;
if (txnAmount.compareTo(BigDecimal.ZERO) <= 0) {
throw apiError(400, "Total transaction amount must be greater than zero");
}
// Source GL is required for disbursement; collection uses DR savings entries instead
final boolean hasDrEntries = totalDr.compareTo(BigDecimal.ZERO) > 0;
if ((glAccountNo == null || glAccountNo.isBlank()) && !hasDrEntries) {
throw apiError(400, "Source GL account (disbursement) or DR savings entries (collection) are required");
}
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
@@ -140,7 +161,7 @@ public class ClerkDisbursementRequestApiResource {
RETURNING id
""",
Long.class,
glAccountId, glAccountNo, glAccountName, totalCr,
glAccountId, glAccountNo, glAccountName, txnAmount,
officeId != null ? officeId : 1L, currencyCode,
creditEntriesJson, paymentTypeId, paymentTypeName, description,
"PENDING",
@@ -150,12 +171,12 @@ public class ClerkDisbursementRequestApiResource {
final String voucherNo = voucherService.createVoucher(
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
newId, totalCr, currentUser);
newId, txnAmount, currentUser);
jdbcTemplate.update(
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING",
"voucherNo", voucherNo, "totalAmount", totalCr));
"voucherNo", voucherNo, "totalAmount", txnAmount));
}
// ── GET /v1/clerk/disbursement-requests ── Manager sees all requests ─────────
@@ -295,73 +316,139 @@ public class ClerkDisbursementRequestApiResource {
final String description = (String) record.get("description");
final String txnDateStr = LocalDate.now().toString();
// Validate all entries have account IDs before posting any transaction
final Long sourceGlId = record.get("gl_account_id") != null
? ((Number) record.get("gl_account_id")).longValue() : null;
final Long officeId = record.get("office_id") != null
? ((Number) record.get("office_id")).longValue() : 1L;
final String currencyCode = record.get("currency_code") != null
? (String) record.get("currency_code") : "INR";
// Validate all entries have required IDs before posting any transaction
for (int i = 0; i < creditEntries.size(); i++) {
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
final boolean hasId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull();
if (!hasId) {
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
throw apiError(400, "Account ID missing for credit entry: " + acNo
+ ". Clerk must re-submit with account details loaded.");
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
+ ". 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
+ ". Clerk must re-submit with account details loaded.");
}
}
}
// Detect collection mode: any DR entry in creditEntries = collection (savings debit)
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;
}
}
final List<String> txnRefs = new ArrayList<>();
final List<String> savingsTxnRefs = 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;
// Post savings/current deposit for each CR entry — all or nothing (rollback on any failure)
// Process each entry — all or nothing (@Transactional rolls back on any failure)
for (final JsonElement el : creditEntries) {
final JsonObject entry = el.getAsJsonObject();
final Long savingsAccountId = entry.get("savingsAccountId").getAsLong();
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);
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(savingsAccountId);
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
final String txnId;
try {
if (isCurrentAccount) {
txnId = executeCurrentAccountDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
} else {
txnId = executeSavingsDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
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;
}
}
} catch (Exception e) {
// Rethrow so @Transactional rolls back all previously posted deposits
throw apiError(422, "Failed to credit account " + acNo + ": "
+ (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
txnRefs.add("GL:" + acNo);
continue;
}
if (txnId != null) {
txnRefs.add(txnId);
if (!isCurrentAccount) savingsTxnRefs.add(txnId);
// 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);
depositSavingsTotal = depositSavingsTotal.add(entryAmt);
}
}
} else {
// Withdrawal — debit savings/current account (collection)
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);
}
}
}
}
// Post offsetting GL journal entry: DR source GL / CR Cash at Vault
// Fineract's savings deposit auto-creates "DR Cash at Vault / CR Savings" via product GL mapping.
// This offsetting entry makes the net accounting: DR source GL / CR savings accounts.
final Long sourceGlId = record.get("gl_account_id") != null
? ((Number) record.get("gl_account_id")).longValue() : null;
if (sourceGlId != null && !savingsTxnRefs.isEmpty()) {
Long cashVaultGlId = null;
for (final String txnRef : savingsTxnRefs) {
final List<Map<String, Object>> jeRows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = 1 LIMIT 1", "S" + txnRef);
if (!jeRows.isEmpty()) {
cashVaultGlId = ((Number) jeRows.get(0).get("account_id")).longValue();
break;
}
}
// 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) {
final BigDecimal totalAmount = (BigDecimal) record.get("dr_amount");
final Long officeId = record.get("office_id") != null
? ((Number) record.get("office_id")).longValue() : 1L;
final String currencyCode = record.get("currency_code") != null
? (String) record.get("currency_code") : "INR";
postGlOffsetEntry(sourceGlId, cashVaultGlId, totalAmount, officeId,
currencyCode, txnDateStr, description);
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);
}
}
@@ -489,6 +576,9 @@ 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;
}
@@ -510,6 +600,7 @@ public class ClerkDisbursementRequestApiResource {
.currentAccountDeposit(currentAccountId)
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
@@ -554,6 +645,74 @@ public class ClerkDisbursementRequestApiResource {
return gson.toJson(log);
}
private String extractResourceIdFromResponse(final String responseJson) {
if (responseJson == null || responseJson.isBlank()) return null;
try {
final JsonObject obj = gson.fromJson(responseJson, JsonObject.class);
return obj.has("resourceId") && !obj.get("resourceId").isJsonNull()
? obj.get("resourceId").getAsString() : null;
} catch (Exception e) {
return null;
}
}
private String executeSavingsWithdrawal(
final Long savingsAccountId, final BigDecimal amount,
final Long paymentTypeId, final String txnDateStr, final String note) {
final String date = formatForFineract(txnDateStr);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId);
if (note != null && !note.isBlank()) payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder()
.savingsAccountWithdrawal(savingsAccountId)
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private String executeCurrentAccountWithdrawal(
final Long currentAccountId, final BigDecimal amount,
final Long paymentTypeId, final String txnDateStr, final String note) {
final String date = formatForFineract(txnDateStr);
final JsonObject payload = new JsonObject();
payload.addProperty("locale", "en");
payload.addProperty("dateFormat", "dd MMMM yyyy");
payload.addProperty("transactionDate", date);
payload.addProperty("transactionAmount", amount);
if (paymentTypeId != null) payload.addProperty("paymentTypeId", paymentTypeId);
if (note != null && !note.isBlank()) payload.addProperty("note", note);
final CommandWrapper cmd = new CommandWrapperBuilder()
.currentAccountWithdrawal(currentAccountId)
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
return result.getResourceId() != null ? result.getResourceId().toString() : null;
}
private Long findCashVaultGlId(final List<String> txnRefs, final int typeEnum) {
for (final String txnRef : txnRefs) {
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(
"SELECT account_id FROM acc_gl_journal_entry"
+ " WHERE transaction_id = ? AND type_enum = ? LIMIT 1",
"S" + txnRef, typeEnum);
if (!rows.isEmpty()) {
return ((Number) rows.get(0).get("account_id")).longValue();
}
}
return null;
}
private void postGlOffsetEntry(
final Long drGlAccountId, final Long crGlAccountId,
final BigDecimal amount, final Long officeId,
@@ -579,7 +738,7 @@ public class ClerkDisbursementRequestApiResource {
payload.addProperty("locale", "en");
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
if (comments != null && !comments.isBlank()) {
payload.addProperty("comments", "Disbursement GL offset: " + comments);
payload.addProperty("comments", "GL offset: " + comments);
}
payload.add("debits", debits);
payload.add("credits", credits);
@@ -588,6 +747,7 @@ public class ClerkDisbursementRequestApiResource {
.createJournalEntry()
.withJson(payload.toString())
.build();
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
commandsSourceWritePlatformService.logCommandSource(cmd);
}
}
@@ -287,8 +287,8 @@ public class ClerkWithdrawalRequestApiResource {
case "APPROVED" -> gson.toJson(rows.get(0));
case "PENDING" -> throw tokenError(422,
"Token '" + token + "' is still PENDING — manager has not approved this request yet.");
case "DISBURSED" -> throw tokenError(422,
"Token '" + token + "' has already been disbursed. Cash was handed over by another cashier.");
case "WITHDRAWN" -> throw tokenError(422,
"Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
case "REJECTED" -> throw tokenError(422,
"Token '" + token + "' was REJECTED by the manager. This withdrawal cannot be processed.");
default -> throw tokenError(422,
@@ -573,13 +573,13 @@ public class ClerkWithdrawalRequestApiResource {
}
final String updatedLog = buildActivityLog(
(String) record.get("activity_log"), "DISBURSED", currentUser.getUsername(), null
(String) record.get("activity_log"), "WITHDRAWN", currentUser.getUsername(), null
);
// WHERE status = 'APPROVED' prevents race condition — only one cashier can win
final int updated = jdbcTemplate.update("""
UPDATE m_clerk_withdrawal_request SET
status = 'DISBURSED', cashier_id = ?, disbursed_by = ?,
status = 'WITHDRAWN', cashier_id = ?, disbursed_by = ?,
disbursement_denomination_data = ?, activity_log = ?, disbursed_at = ?
WHERE id = ? AND status = 'APPROVED'
""",
@@ -588,7 +588,7 @@ public class ClerkWithdrawalRequestApiResource {
);
if (updated == 0) {
throw apiError(409, "This withdrawal has already been disbursed by another cashier. Double disbursement prevented.");
throw apiError(409, "This withdrawal has already been withdrawn by another cashier. Double disbursement prevented.");
}
// Record denomination breakdown in m_savings_txn_denomination so cashier vault position updates
@@ -597,7 +597,7 @@ public class ClerkWithdrawalRequestApiResource {
}
final Object voucherNoObj = record.get("voucher_no");
return gson.toJson(Map.of("resourceId", id, "status", "DISBURSED",
return gson.toJson(Map.of("resourceId", id, "status", "WITHDRAWN",
"voucherNo", voucherNoObj != null ? voucherNoObj.toString() : ""));
}
@@ -360,4 +360,5 @@
<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"/>
<include file="parts/0332_create_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
<include file="parts/0333_rename_disbursed_to_withdrawn.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,16 @@
<?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="0333_rename_disbursed_to_withdrawn" author="CBS">
<preConditions onFail="MARK_RAN">
<tableExists tableName="m_clerk_withdrawal_request"/>
</preConditions>
<sql>
UPDATE m_clerk_withdrawal_request SET status = 'WITHDRAWN' WHERE status = 'DISBURSED'
</sql>
</changeSet>
</databaseChangeLog>