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 fbf0d9a5c..3d8a3f413 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 @@ -45,10 +45,12 @@ import java.time.LocalDate; import java.time.OffsetDateTime; import java.time.format.DateTimeFormatter; import java.util.ArrayList; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import org.springframework.jdbc.support.GeneratedKeyHolder; import org.springframework.jdbc.support.KeyHolder; @@ -104,6 +106,11 @@ public class ClerkDisbursementRequestApiResource { final String paymentTypeName = fromJsonHelper.extractStringNamed("paymentTypeName", root); final String description = fromJsonHelper.extractStringNamed("description", root); final String creditEntriesJson = fromJsonHelper.extractStringNamed("creditEntries", root); + String requestType = fromJsonHelper.extractStringNamed("requestType", root); + if (requestType == null || requestType.isBlank() + || !java.util.Set.of("DISBURSEMENT", "COLLECTION", "BANK_TRANSFER").contains(requestType)) { + requestType = "DISBURSEMENT"; + } if (creditEntriesJson == null || creditEntriesJson.isBlank()) { throw apiError(400, "At least one entry is required"); @@ -164,8 +171,8 @@ public class ClerkDisbursementRequestApiResource { INSERT INTO m_clerk_disbursement_request (gl_account_id, gl_account_no, gl_account_name, dr_amount, office_id, currency_code, credit_entries, payment_type_id, payment_type_name, description, - status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) + status, clerk_id, clerk_name, clerk_username, activity_log, submitted_at, request_type) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?) RETURNING id """, Long.class, @@ -174,7 +181,7 @@ public class ClerkDisbursementRequestApiResource { creditEntriesJson, paymentTypeId, paymentTypeName, description, "PENDING", currentUser.getId(), currentUser.getDisplayName(), currentUser.getUsername(), - activityLog, Timestamp.from(Instant.now()) + activityLog, Timestamp.from(Instant.now()), requestType ); final String voucherNo = voucherService.createVoucher( @@ -240,7 +247,7 @@ public class ClerkDisbursementRequestApiResource { pageParams.add(offset); final List> rows = jdbcTemplate.queryForList( - "SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount," + "SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount," + " credit_entries, payment_type_name, description," + " clerk_id, clerk_name, clerk_username," + " manager_id, manager_name, manager_username," @@ -309,7 +316,7 @@ public class ClerkDisbursementRequestApiResource { pageParams.add(offset); final List> rows = jdbcTemplate.queryForList( - "SELECT id, voucher_no, status, gl_account_no, gl_account_name, dr_amount," + "SELECT id, voucher_no, status, request_type, gl_account_no, gl_account_name, dr_amount," + " credit_entries, payment_type_name, description, clerk_name," + " manager_name, rejection_reason, txn_refs, error_message, submitted_at, actioned_at" + " FROM m_clerk_disbursement_request " + where @@ -405,13 +412,21 @@ public class ClerkDisbursementRequestApiResource { 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) + // Determine request type: BANK_TRANSFER uses direct debit/credit on both sides (no GL). + final String requestType = record.get("request_type") != null + ? (String) record.get("request_type") : "DISBURSEMENT"; + final boolean isBankTransferMode = "BANK_TRANSFER".equals(requestType); + + // Detect collection mode: any DR entry = collection (savings debits → GL credit). + // Not applicable for bank transfer (it has both DR and CR savings entries). boolean isCollectionMode = false; - for (final JsonElement el : creditEntries) { - final JsonObject e = el.getAsJsonObject(); - if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) { - isCollectionMode = true; - break; + if (!isBankTransferMode) { + for (final JsonElement el : creditEntries) { + final JsonObject e = el.getAsJsonObject(); + if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) { + isCollectionMode = true; + break; + } } } @@ -432,139 +447,235 @@ public class ClerkDisbursementRequestApiResource { 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 (isBankTransferMode) { + // ── Bank Transfer: DR savings/CA → CR savings/CA, no external GL ──── + // Execute direct debit on each DR account and direct credit on each CR account. + // GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account). + final List btDebitGlEntries = new ArrayList<>(); + final List btCreditGlEntries = new ArrayList<>(); + BigDecimal btTotalDr = BigDecimal.ZERO; - 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; - } + 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 acNo = entry.has("accountNo") ? entry.get("accountNo").getAsString() : "?"; + final Long accountId = entry.get("savingsAccountId").getAsLong(); + final boolean isCurrent = "CURRENT".equals( + entry.has("accountType") ? entry.get("accountType").getAsString() : "SAVINGS"); + + final Long ctrlGl = isCurrent + ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == null) throw new RuntimeException( + "No savings-control GL mapping found for account " + acNo + + ". Check acc_product_mapping (product_type=2, financial_account_type=2)."); + + if ("Dr".equals(dbCr)) { + final long txnId = isCurrent + ? directDebitCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description); + txnRefs.add(String.valueOf(txnId)); + btDebitGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + btTotalDr = btTotalDr.add(entryAmt); + if (isCurrent) undoOps.add(() -> directReverseCurrentDebit(accountId, entryAmt, txnId)); + else undoOps.add(() -> directReverseSavingsDebit(accountId, entryAmt, txnId)); + } else { + final long txnId = isCurrent + ? directCreditCurrentAccount(accountId, entryAmt, officeId, txnDateStr, currentUser, description) + : directCreditSavingsAccount(accountId, entryAmt, officeId, txnDateStr, currentUser); + txnRefs.add(String.valueOf(txnId)); + btCreditGlEntries.add(new GlEntry(ctrlGl, entryAmt, acNo, null, accountId)); + if (isCurrent) undoOps.add(() -> directReverseCurrentCredit(accountId, entryAmt, txnId)); + else undoOps.add(() -> directReverseSavingsCredit(accountId, entryAmt, txnId)); } - txnRefs.add("GL:" + acNo); - continue; } - final Long accountId = entry.get("savingsAccountId").getAsLong(); - final boolean isCurrentAccount = isCurrentAccountById(accountId); - String txnId = null; + // Combined GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account) + if (!btDebitGlEntries.isEmpty() && !btCreditGlEntries.isEmpty()) { + final String btComment = buildGlEntryComment("Bank Transfer", btDebitGlEntries, description) + + " → " + buildGlEntryComment("", btCreditGlEntries, null); + final String btGlTxnId = postCombinedJournalEntry( + btDebitGlEntries, btCreditGlEntries, officeId, currencyCode, txnDateStr, btComment); + if (btGlTxnId != null) { + linkGlEntriesToSavingsAccounts(btGlTxnId, btDebitGlEntries, 2); + linkGlEntriesToSavingsAccounts(btGlTxnId, btCreditGlEntries, 1); + } - if ("Cr".equals(dbCr)) { - 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)."); + // Audit log in m_bank_transfer_log + final String btTxnRefsStr = String.join(",", txnRefs); + final BigDecimal finalBtTotal = btTotalDr; + final String finalBtGlTxnId = btGlTxnId; + final JsonArray btDrArr = new JsonArray(); + final JsonArray btCrArr = new JsonArray(); + for (final JsonElement el : creditEntries) { + final JsonObject e = el.getAsJsonObject(); + if ("Dr".equals(e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr")) btDrArr.add(e); + else btCrArr.add(e); + } + final KeyHolder btLogKh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_bank_transfer_log " + + "(voucher_no, office_id, currency_code, total_amount, description, " + + "debit_entries, credit_entries, txn_refs, gl_txn_id, " + + "teller_id, teller_name, teller_username, status, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'COMPLETED', CURRENT_TIMESTAMP)", + new String[]{"id"}); + ps.setString(1, "BT-TMP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)); + ps.setLong(2, officeId); + ps.setString(3, currencyCode); + ps.setBigDecimal(4, finalBtTotal); + ps.setString(5, description); + ps.setString(6, gson.toJson(btDrArr)); + ps.setString(7, gson.toJson(btCrArr)); + ps.setString(8, btTxnRefsStr); + ps.setString(9, finalBtGlTxnId); + ps.setLong(10, currentUser.getId()); + ps.setString(11, currentUser.getDisplayName()); + ps.setString(12, currentUser.getUsername()); + return ps; + }, btLogKh); + final long btLogId = Objects.requireNonNull(btLogKh.getKey()).longValue(); + final String btVoucherNo = generateBtVoucherNo(btLogId); + jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", btVoucherNo, btLogId); + } + + } else { + // ── Disbursement / Collection mode ──────────────────────────────── + 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 ("Cr".equals(dbCr)) { + 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)); } - 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)."); + try { + txnId = isCurrentAccount + ? executeCurrentAccountWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description) + : executeSavingsWithdrawal(accountId, entryAmt, entryPtId, txnDateStr, description); + } catch (IdempotentCommandProcessSucceedException idempotent) { + txnId = extractResourceIdFromResponse(idempotent.getResponse()); } - 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)); + 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)); + } } } } } - } - // ── 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)), + // ── 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("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); + 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); + } + } + } + } // end else (disbursement/collection mode) } catch (Exception ex) { // Compensate: undo all posted Fineract transactions in reverse order (best-effort). @@ -664,6 +775,227 @@ public class ClerkDisbursementRequestApiResource { return gson.toJson(Map.of("resourceId", id, "status", "REJECTED")); } + // ── POST /v1/clerk/disbursement-requests/banktransfer ── Intra-bank transfer ── + + /** + * Real CBS intra-bank book transfer: debits N savings/current accounts and credits + * N savings/current accounts in one atomic transaction. No GL source/target from user — + * GL is auto-determined (SavingsControl per account product). Immediate execution + * (no maker-checker) — teller authority level. + * + * GL: DR SavingsControl(per DR account) / CR SavingsControl(per CR account). + * If both accounts share the same product, net GL impact = zero (same control account). + */ + @POST + @Path("/banktransfer") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + @Transactional + public String bankTransfer(final String body) { + final AppUser currentUser = context.authenticatedUser(); + + final JsonElement root = fromJsonHelper.parse(body != null && !body.isBlank() ? body : "{}"); + final JsonObject req = root.getAsJsonObject(); + + final Long officeId = req.has("officeId") && !req.get("officeId").isJsonNull() + ? req.get("officeId").getAsLong() : 1L; + final String currencyCode = req.has("currencyCode") && !req.get("currencyCode").isJsonNull() + ? req.get("currencyCode").getAsString() : "INR"; + final String txnDateStr = req.has("transactionDate") && !req.get("transactionDate").isJsonNull() + ? req.get("transactionDate").getAsString() + : LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + final String description = req.has("description") && !req.get("description").isJsonNull() + ? req.get("description").getAsString() : null; + + final JsonArray entries = req.has("entries") ? req.getAsJsonArray("entries") : new JsonArray(); + + final List drEntries = new ArrayList<>(); + final List crEntries = new ArrayList<>(); + for (final JsonElement el : entries) { + final JsonObject e = el.getAsJsonObject(); + final String dbCr = e.has("dbCr") ? e.get("dbCr").getAsString() : "Cr"; + if ("Dr".equals(dbCr)) drEntries.add(e); + else crEntries.add(e); + } + + if (drEntries.isEmpty()) throw apiError(400, "At least one DR (debit) account entry is required."); + if (crEntries.isEmpty()) throw apiError(400, "At least one CR (credit) account entry is required."); + + // Validate all entries: savings/current only (no GL), savingsAccountId present, amount > 0 + final Set drAccountIds = new LinkedHashSet<>(); + final Set crAccountIds = new LinkedHashSet<>(); + + for (final JsonObject e : drEntries) { + final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; + if ("GL".equals(acTyp)) throw apiError(400, "GL accounts are not allowed in bank transfer. Use Disbursement/Collection instead."); + if (!e.has("savingsAccountId") || e.get("savingsAccountId").isJsonNull()) + throw apiError(400, "savingsAccountId is required for all entries. Account: " + + (e.has("accountNo") ? e.get("accountNo").getAsString() : "?")); + final BigDecimal amt = new BigDecimal(e.get("amount").getAsString()); + if (amt.compareTo(BigDecimal.ZERO) <= 0) + throw apiError(400, "Amount must be greater than zero. Account: " + + (e.has("accountNo") ? e.get("accountNo").getAsString() : "?")); + drAccountIds.add(e.get("savingsAccountId").getAsLong()); + } + for (final JsonObject e : crEntries) { + final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; + if ("GL".equals(acTyp)) throw apiError(400, "GL accounts are not allowed in bank transfer. Use Disbursement/Collection instead."); + if (!e.has("savingsAccountId") || e.get("savingsAccountId").isJsonNull()) + throw apiError(400, "savingsAccountId is required for all entries. Account: " + + (e.has("accountNo") ? e.get("accountNo").getAsString() : "?")); + final BigDecimal amt = new BigDecimal(e.get("amount").getAsString()); + if (amt.compareTo(BigDecimal.ZERO) <= 0) + throw apiError(400, "Amount must be greater than zero. Account: " + + (e.has("accountNo") ? e.get("accountNo").getAsString() : "?")); + final Long crId = e.get("savingsAccountId").getAsLong(); + if (drAccountIds.contains(crId)) throw apiError(400, + "Account " + (e.has("accountNo") ? e.get("accountNo").getAsString() : crId) + + " cannot appear on both DR and CR sides of the same transfer."); + crAccountIds.add(crId); + } + + // Validate balanced entry: total DR == total CR + BigDecimal totalDr = BigDecimal.ZERO; + BigDecimal totalCr = BigDecimal.ZERO; + for (final JsonObject e : drEntries) totalDr = totalDr.add(new BigDecimal(e.get("amount").getAsString())); + for (final JsonObject e : crEntries) totalCr = totalCr.add(new BigDecimal(e.get("amount").getAsString())); + if (totalDr.compareTo(totalCr) != 0) { + throw apiError(400, "Unbalanced entry: DR total (" + totalDr + ") ≠ CR total (" + totalCr + "). Bank transfer must balance."); + } + + final List undoOps = new ArrayList<>(); + final List txnRefs = new ArrayList<>(); + final List debitGlEntries = new ArrayList<>(); + final List creditGlEntries = new ArrayList<>(); + String glTxnId = null; + + try { + // ── Process DR entries (debit accounts) ─────────────────────────────── + for (final JsonObject e : drEntries) { + final Long accountId = e.get("savingsAccountId").getAsLong(); + final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); + final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; + final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; + final boolean isCurrent = "CURRENT".equals(acTyp); + + final Long ctrlGl = isCurrent + ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == 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 txnId = isCurrent + ? directDebitCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) + : directDebitSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser, description); + + txnRefs.add(String.valueOf(txnId)); + debitGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + if (isCurrent) { + undoOps.add(() -> directReverseCurrentDebit(accountId, amount, txnId)); + } else { + undoOps.add(() -> directReverseSavingsDebit(accountId, amount, txnId)); + } + } + + // ── Process CR entries (credit accounts) ───────────────────────────── + for (final JsonObject e : crEntries) { + final Long accountId = e.get("savingsAccountId").getAsLong(); + final BigDecimal amount = new BigDecimal(e.get("amount").getAsString()); + final String acNo = e.has("accountNo") ? e.get("accountNo").getAsString() : "?"; + final String acTyp = e.has("accountType") ? e.get("accountType").getAsString() : "SAVINGS"; + final boolean isCurrent = "CURRENT".equals(acTyp); + + final Long ctrlGl = isCurrent + ? getCurrentControlGlId(accountId) : getSavingsControlGlId(accountId); + if (ctrlGl == 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 txnId = isCurrent + ? directCreditCurrentAccount(accountId, amount, officeId, txnDateStr, currentUser, description) + : directCreditSavingsAccount(accountId, amount, officeId, txnDateStr, currentUser); + txnRefs.add(String.valueOf(txnId)); + creditGlEntries.add(new GlEntry(ctrlGl, amount, acNo, null, accountId)); + if (isCurrent) { + undoOps.add(() -> directReverseCurrentCredit(accountId, amount, txnId)); + } else { + undoOps.add(() -> directReverseSavingsCredit(accountId, amount, txnId)); + } + } + + // ── Combined GL entry: DR SavingsControl(per DR) / CR SavingsControl(per CR) ─ + final String btComment = buildGlEntryComment("Bank Transfer", debitGlEntries, description) + + " → " + buildGlEntryComment("", creditGlEntries, null); + glTxnId = postCombinedJournalEntry( + debitGlEntries, creditGlEntries, + officeId, currencyCode, txnDateStr, btComment); + if (glTxnId != null) { + linkGlEntriesToSavingsAccounts(glTxnId, debitGlEntries, 2); + linkGlEntriesToSavingsAccounts(glTxnId, creditGlEntries, 1); + } + + } catch (Exception ex) { + for (int i = undoOps.size() - 1; i >= 0; i--) { + try { undoOps.get(i).run(); } catch (Exception undoEx) { + log.warn("Bank transfer undo op {} failed: {}", i, undoEx.getMessage()); + } + } + final String errMsg = ex.getMessage() != null ? ex.getMessage() : ex.getClass().getSimpleName(); + log.error("Bank transfer failed by {}: {}", currentUser.getUsername(), errMsg); + throw apiError(422, "Bank transfer failed and has been reversed: " + errMsg); + } + + // ── Audit log ───────────────────────────────────────────────────────────── + // Insert first with a placeholder voucher_no, then update with the generated ID + // so the voucher number is guaranteed unique (ID comes from DB sequence). + final String txnRefsStr = String.join(",", txnRefs); + final BigDecimal finalTotalDr = totalDr; // effectively-final copy for lambda + final String finalGlTxnId = glTxnId; // effectively-final copy for lambda + final KeyHolder logKh = new GeneratedKeyHolder(); + jdbcTemplate.update(con -> { + final PreparedStatement ps = con.prepareStatement( + "INSERT INTO m_bank_transfer_log " + + "(voucher_no, office_id, currency_code, total_amount, description, " + + "debit_entries, credit_entries, txn_refs, gl_txn_id, " + + "teller_id, teller_name, teller_username, status, created_at) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'COMPLETED', CURRENT_TIMESTAMP)", + new String[]{"id"}); + ps.setString(1, "BT-TMP-" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)); + ps.setLong(2, officeId); + ps.setString(3, currencyCode); + ps.setBigDecimal(4, finalTotalDr); + ps.setString(5, description); + ps.setString(6, gson.toJson(drEntries)); + ps.setString(7, gson.toJson(crEntries)); + ps.setString(8, txnRefsStr); + ps.setString(9, finalGlTxnId); + ps.setLong(10, currentUser.getId()); + ps.setString(11, currentUser.getDisplayName()); + ps.setString(12, currentUser.getUsername()); + return ps; + }, logKh); + final long logId = Objects.requireNonNull(logKh.getKey()).longValue(); + final String voucherNo = generateBtVoucherNo(logId); + jdbcTemplate.update("UPDATE m_bank_transfer_log SET voucher_no = ? WHERE id = ?", voucherNo, logId); + + final JsonObject res = new JsonObject(); + res.addProperty("voucherNo", voucherNo); + res.addProperty("txnRefs", txnRefsStr); + res.addProperty("totalAmount", totalDr); + res.addProperty("status", "COMPLETED"); + return gson.toJson(res); + } + + /** + * Generates a unique bank-transfer voucher number: BT + yyMMdd + 5-digit sequence. + * Uses MAX(id) of the just-inserted row (passed in) to guarantee uniqueness even + * under concurrent inserts on the same date. + */ + private String generateBtVoucherNo(final long logId) { + return "BT" + LocalDate.now().format(DateTimeFormatter.ofPattern("yyMMdd")) + + String.format("%05d", logId); + } + // ── Helpers ─────────────────────────────────────────────────────────────────── private boolean isCheckerRole(final AppUser user) { @@ -696,7 +1028,7 @@ public class ClerkDisbursementRequestApiResource { private Map loadRecord(final Long id) { final List> rows = jdbcTemplate.queryForList(""" - SELECT id, voucher_no, status, gl_account_id, gl_account_no, gl_account_name, dr_amount, + SELECT id, voucher_no, status, request_type, gl_account_id, gl_account_no, gl_account_name, dr_amount, 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, @@ -1174,6 +1506,144 @@ public class ClerkDisbursementRequestApiResource { amount, amount, accountId); } + /** + * Real CBS book transfer — direct DEBIT (withdrawal) from a savings account. + * INSERTs a withdrawal transaction (transaction_type_enum=2) into m_savings_account_transaction + * and subtracts from account_balance_derived. Validates balance ≥ amount before proceeding. + */ + private long directDebitSavingsAccount(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_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; + if (currentBalance.compareTo(amount) < 0) throw new RuntimeException( + "Insufficient balance in savings account " + accountId + + ". Available: " + currentBalance + ", Requested: " + amount); + + 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, 2, 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.subtract(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_withdrawals_derived = total_withdrawals_derived + ? " + + "WHERE id = ?", + amount, amount, accountId); + return txnId; + } + + /** Compensation for directDebitSavingsAccount — marks transaction reversed and restores balance. */ + private void directReverseSavingsDebit(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_withdrawals_derived = total_withdrawals_derived - ? " + + "WHERE id = ?", + amount, amount, accountId); + } + + /** + * Real CBS book transfer — direct DEBIT (withdrawal) from a current account. + * INSERTs a withdrawal transaction (transaction_type_enum=2) into m_current_account_transaction + * and subtracts from account_balance_derived. Validates balance ≥ amount before proceeding. + */ + private long directDebitCurrentAccount(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; + if (currentBalance.compareTo(amount) < 0) throw new RuntimeException( + "Insufficient balance in current account " + accountId + + ". Available: " + currentBalance + ", Requested: " + amount); + + 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 (?, ?, 2, ?, ?, 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.subtract(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_withdrawals_derived = total_withdrawals_derived + ? " + + "WHERE id = ?", + amount, amount, accountId); + return txnId; + } + + /** Compensation for directDebitCurrentAccount — marks transaction reversed and restores balance. */ + private void directReverseCurrentDebit(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_withdrawals_derived = total_withdrawals_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 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 c6b4cd18c..cde8246ba 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 @@ -362,4 +362,6 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0335_create_bank_transfer_log.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0335_create_bank_transfer_log.xml new file mode 100644 index 000000000..628db1c95 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0335_create_bank_transfer_log.xml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0336_add_request_type_to_clerk_disbursement.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0336_add_request_type_to_clerk_disbursement.xml new file mode 100644 index 000000000..938f6b492 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0336_add_request_type_to_clerk_disbursement.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + +