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:
co-authored by
Claude Sonnet 4.6
parent
a6eae3d6e8
commit
d002fc0c9b
+222
-62
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -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>
|
||||
|
||||
+16
@@ -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>
|
||||
Reference in New Issue
Block a user