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:
co-authored by
Claude Sonnet 4.6
parent
d002fc0c9b
commit
bf5ce421df
+8
-4
@@ -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);
|
||||
|
||||
+605
-118
@@ -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>
|
||||
|
||||
+17
@@ -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>
|
||||
Reference in New Issue
Block a user