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.domain.CommandWrapper;
|
||||||
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
import org.apache.fineract.commands.service.CommandWrapperBuilder;
|
||||||
import org.apache.fineract.commands.service.PortfolioCommandSourceWritePlatformService;
|
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.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.core.serialization.FromJsonHelper;
|
||||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||||
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
import org.apache.fineract.portfolio.voucher.service.VoucherService;
|
||||||
@@ -69,6 +72,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
private final FromJsonHelper fromJsonHelper;
|
private final FromJsonHelper fromJsonHelper;
|
||||||
private final PlatformSecurityContext context;
|
private final PlatformSecurityContext context;
|
||||||
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
private final PortfolioCommandSourceWritePlatformService commandsSourceWritePlatformService;
|
||||||
|
private final FineractRequestContextHolder fineractRequestContextHolder;
|
||||||
private final VoucherService voucherService;
|
private final VoucherService voucherService;
|
||||||
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
private final Gson gson = new GsonBuilder().serializeNulls().create();
|
||||||
|
|
||||||
@@ -93,14 +97,11 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
final String description = fromJsonHelper.extractStringNamed("description", root);
|
final String description = fromJsonHelper.extractStringNamed("description", root);
|
||||||
final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", 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()) {
|
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;
|
final JsonArray creditEntries;
|
||||||
try {
|
try {
|
||||||
creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
|
creditEntries = gson.fromJson(creditEntriesJson, JsonArray.class);
|
||||||
@@ -108,25 +109,45 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
throw apiError(400, "creditEntries is not valid JSON");
|
throw apiError(400, "creditEntries is not valid JSON");
|
||||||
}
|
}
|
||||||
if (creditEntries == null || creditEntries.size() == 0) {
|
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 totalCr = BigDecimal.ZERO;
|
||||||
|
BigDecimal totalDr = BigDecimal.ZERO;
|
||||||
for (final JsonElement el : creditEntries) {
|
for (final JsonElement el : creditEntries) {
|
||||||
final JsonObject entry = el.getAsJsonObject();
|
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 String amt = entry.has("amount") ? entry.get("amount").getAsString() : null;
|
||||||
final BigDecimal entryAmt = parseBigDecimal(amt);
|
final BigDecimal entryAmt = parseBigDecimal(amt);
|
||||||
if (entryAmt == null || entryAmt.compareTo(BigDecimal.ZERO) <= 0) {
|
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()) {
|
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) {
|
// Validate balance when both DR and CR entries present (collection mode)
|
||||||
throw apiError(400, "Total credit amount must be greater than zero");
|
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);
|
final String activityLog = buildActivityLog(null, "SUBMITTED", currentUser.getUsername(), null);
|
||||||
@@ -140,7 +161,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
RETURNING id
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
Long.class,
|
Long.class,
|
||||||
glAccountId, glAccountNo, glAccountName, totalCr,
|
glAccountId, glAccountNo, glAccountName, txnAmount,
|
||||||
officeId != null ? officeId : 1L, currencyCode,
|
officeId != null ? officeId : 1L, currencyCode,
|
||||||
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
creditEntriesJson, paymentTypeId, paymentTypeName, description,
|
||||||
"PENDING",
|
"PENDING",
|
||||||
@@ -150,12 +171,12 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
|
|
||||||
final String voucherNo = voucherService.createVoucher(
|
final String voucherNo = voucherService.createVoucher(
|
||||||
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
VoucherService.TYPE_DISBURSEMENT, VoucherService.REF_CLERK_DISBURSEMENT_REQUEST,
|
||||||
newId, totalCr, currentUser);
|
newId, txnAmount, currentUser);
|
||||||
jdbcTemplate.update(
|
jdbcTemplate.update(
|
||||||
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
"UPDATE m_clerk_disbursement_request SET voucher_no = ? WHERE id = ?", voucherNo, newId);
|
||||||
|
|
||||||
return gson.toJson(Map.of("resourceId", newId, "status", "PENDING",
|
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 ─────────
|
// ── 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 description = (String) record.get("description");
|
||||||
final String txnDateStr = LocalDate.now().toString();
|
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++) {
|
for (int i = 0; i < creditEntries.size(); i++) {
|
||||||
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
|
final JsonObject entry = creditEntries.get(i).getAsJsonObject();
|
||||||
final boolean hasId = entry.has("savingsAccountId") && !entry.get("savingsAccountId").isJsonNull();
|
final String entryType = entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS";
|
||||||
if (!hasId) {
|
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
|
||||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "entry " + (i + 1);
|
if ("GL".equals(entryType)) {
|
||||||
throw apiError(400, "Account ID missing for credit entry: " + acNo
|
if (!entry.has("glAccountId") || entry.get("glAccountId").isJsonNull()) {
|
||||||
+ ". Clerk must re-submit with account details loaded.");
|
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> 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) {
|
for (final JsonElement el : creditEntries) {
|
||||||
final JsonObject entry = el.getAsJsonObject();
|
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 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()
|
final Long entryPtId = entry.has("paymentTypeId") && !entry.get("paymentTypeId").isJsonNull()
|
||||||
? entry.get("paymentTypeId").getAsLong()
|
? entry.get("paymentTypeId").getAsLong()
|
||||||
: (ptId != null ? ((Number) ptId).longValue() : null);
|
: (ptId != null ? ((Number) ptId).longValue() : null);
|
||||||
final String acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : String.valueOf(savingsAccountId);
|
|
||||||
|
|
||||||
final boolean isCurrentAccount = isCurrentAccountById(savingsAccountId);
|
if ("GL".equals(entryType)) {
|
||||||
final String txnId;
|
final Long glId = entry.get("glAccountId").getAsLong();
|
||||||
try {
|
if ("Cr".equals(dbCr)) {
|
||||||
if (isCurrentAccount) {
|
if (!isCollectionMode && sourceGlId != null) {
|
||||||
txnId = executeCurrentAccountDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
|
// Disbursement: direct GL-to-GL — DR sourceGL / CR thisGL
|
||||||
} else {
|
postGlOffsetEntry(sourceGlId, glId, entryAmt, officeId, currencyCode, txnDateStr, description);
|
||||||
txnId = executeSavingsDeposit(savingsAccountId, entryAmt, entryPtId, txnDateStr, description);
|
} else {
|
||||||
|
// Collection: CR GL is the target for the Cash@Vault offset
|
||||||
|
collectionTargetGlId = glId;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
txnRefs.add("GL:" + acNo);
|
||||||
// Rethrow so @Transactional rolls back all previously posted deposits
|
continue;
|
||||||
throw apiError(422, "Failed to credit account " + acNo + ": "
|
|
||||||
+ (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName()));
|
|
||||||
}
|
}
|
||||||
if (txnId != null) {
|
|
||||||
txnRefs.add(txnId);
|
// Savings or Current account
|
||||||
if (!isCurrentAccount) savingsTxnRefs.add(txnId);
|
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
|
// Disbursement offset: deposits auto-create DR Cash@Vault / CR Savings.
|
||||||
// Fineract's savings deposit auto-creates "DR Cash at Vault / CR Savings" via product GL mapping.
|
// Cancel Cash@Vault side: DR sourceGL / CR Cash@Vault. Net: DR sourceGL / CR Savings.
|
||||||
// This offsetting entry makes the net accounting: DR source GL / CR savings accounts.
|
if (sourceGlId != null && !depositSavingsTxnRefs.isEmpty()) {
|
||||||
final Long sourceGlId = record.get("gl_account_id") != null
|
final Long cashVaultGlId = findCashVaultGlId(depositSavingsTxnRefs, 2); // DEBIT side of deposit entry
|
||||||
? ((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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (cashVaultGlId != null) {
|
if (cashVaultGlId != null) {
|
||||||
final BigDecimal totalAmount = (BigDecimal) record.get("dr_amount");
|
postGlOffsetEntry(sourceGlId, cashVaultGlId, depositSavingsTotal,
|
||||||
final Long officeId = record.get("office_id") != null
|
officeId, currencyCode, txnDateStr, description);
|
||||||
? ((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,
|
// Collection offset: withdrawals auto-create DR Savings / CR Cash@Vault.
|
||||||
currencyCode, txnDateStr, description);
|
// 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)
|
.savingsAccountDeposit(savingsAccountId)
|
||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.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);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
@@ -510,6 +600,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.currentAccountDeposit(currentAccountId)
|
.currentAccountDeposit(currentAccountId)
|
||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
final CommandProcessingResult result = commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
return result.getResourceId() != null ? result.getResourceId().toString() : null;
|
||||||
}
|
}
|
||||||
@@ -554,6 +645,74 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
return gson.toJson(log);
|
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(
|
private void postGlOffsetEntry(
|
||||||
final Long drGlAccountId, final Long crGlAccountId,
|
final Long drGlAccountId, final Long crGlAccountId,
|
||||||
final BigDecimal amount, final Long officeId,
|
final BigDecimal amount, final Long officeId,
|
||||||
@@ -579,7 +738,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
payload.addProperty("locale", "en");
|
payload.addProperty("locale", "en");
|
||||||
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
|
payload.addProperty("currencyCode", currencyCode != null ? currencyCode : "INR");
|
||||||
if (comments != null && !comments.isBlank()) {
|
if (comments != null && !comments.isBlank()) {
|
||||||
payload.addProperty("comments", "Disbursement GL offset: " + comments);
|
payload.addProperty("comments", "GL offset: " + comments);
|
||||||
}
|
}
|
||||||
payload.add("debits", debits);
|
payload.add("debits", debits);
|
||||||
payload.add("credits", credits);
|
payload.add("credits", credits);
|
||||||
@@ -588,6 +747,7 @@ public class ClerkDisbursementRequestApiResource {
|
|||||||
.createJournalEntry()
|
.createJournalEntry()
|
||||||
.withJson(payload.toString())
|
.withJson(payload.toString())
|
||||||
.build();
|
.build();
|
||||||
|
fineractRequestContextHolder.setAttribute(SynchronousCommandProcessingService.COMMAND_SOURCE_ID, null);
|
||||||
commandsSourceWritePlatformService.logCommandSource(cmd);
|
commandsSourceWritePlatformService.logCommandSource(cmd);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+6
-6
@@ -287,8 +287,8 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
case "APPROVED" -> gson.toJson(rows.get(0));
|
case "APPROVED" -> gson.toJson(rows.get(0));
|
||||||
case "PENDING" -> throw tokenError(422,
|
case "PENDING" -> throw tokenError(422,
|
||||||
"Token '" + token + "' is still PENDING — manager has not approved this request yet.");
|
"Token '" + token + "' is still PENDING — manager has not approved this request yet.");
|
||||||
case "DISBURSED" -> throw tokenError(422,
|
case "WITHDRAWN" -> throw tokenError(422,
|
||||||
"Token '" + token + "' has already been disbursed. Cash was handed over by another cashier.");
|
"Token '" + token + "' has already been withdrawn. Cash was handed over by another cashier.");
|
||||||
case "REJECTED" -> throw tokenError(422,
|
case "REJECTED" -> throw tokenError(422,
|
||||||
"Token '" + token + "' was REJECTED by the manager. This withdrawal cannot be processed.");
|
"Token '" + token + "' was REJECTED by the manager. This withdrawal cannot be processed.");
|
||||||
default -> throw tokenError(422,
|
default -> throw tokenError(422,
|
||||||
@@ -573,13 +573,13 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final String updatedLog = buildActivityLog(
|
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
|
// WHERE status = 'APPROVED' prevents race condition — only one cashier can win
|
||||||
final int updated = jdbcTemplate.update("""
|
final int updated = jdbcTemplate.update("""
|
||||||
UPDATE m_clerk_withdrawal_request SET
|
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 = ?
|
disbursement_denomination_data = ?, activity_log = ?, disbursed_at = ?
|
||||||
WHERE id = ? AND status = 'APPROVED'
|
WHERE id = ? AND status = 'APPROVED'
|
||||||
""",
|
""",
|
||||||
@@ -588,7 +588,7 @@ public class ClerkWithdrawalRequestApiResource {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (updated == 0) {
|
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
|
// 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");
|
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() : ""));
|
"voucherNo", voucherNoObj != null ? voucherNoObj.toString() : ""));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -360,4 +360,5 @@
|
|||||||
<include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
<include file="parts/0330_create_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="parts/0331_add_gl_columns_to_cashier_deposit_request.xml" relativeToChangelogFile="true"/>
|
<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/0332_create_clerk_disbursement_request.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="parts/0333_rename_disbursed_to_withdrawn.xml" relativeToChangelogFile="true"/>
|
||||||
</databaseChangeLog>
|
</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