diff --git a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java index 66f577291..10fe6b8b8 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java +++ b/fineract-provider/src/main/java/org/apache/fineract/accounting/journalentry/service/JournalEntryReadPlatformServiceImpl.java @@ -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 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); diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java index 83ed14900..fbf0d9a5c 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkdisbursement/api/ClerkDisbursementRequestApiResource.java @@ -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 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 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 pageParams = new ArrayList<>(baseParams); - pageParams.add(limit); + pageParams.add(safeLimit); pageParams.add(offset); final List> 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 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 pageParams = new ArrayList<>(params); - pageParams.add(limit); + pageParams.add(safeLimit); pageParams.add(offset); final List> 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 txnRefs = new ArrayList<>(); - // Disbursement: savings deposits — for offset DR sourceGL / CR Cash@Vault - final List depositSavingsTxnRefs = new ArrayList<>(); BigDecimal depositSavingsTotal = BigDecimal.ZERO; - // Collection: savings withdrawals — for offset DR Cash@Vault / CR targetGL final List 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 collectionGlEntries = new ArrayList<>(); + final List 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 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 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 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> 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 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 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 entries, final int typeEnum) { + final List 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> 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> 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> 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> 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 debits, final List 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 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(); + } } diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index a39cc16de..c6b4cd18c 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -361,4 +361,5 @@ + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0334_clerk_disbursement_add_error_message.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0334_clerk_disbursement_add_error_message.xml new file mode 100644 index 000000000..bea45830b --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0334_clerk_disbursement_add_error_message.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + + + +