diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java index 2f138064e..1a971b8cb 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/clerkwithdrawal/api/ClerkShareWithdrawalRequestApiResource.java @@ -244,11 +244,47 @@ public class ClerkShareWithdrawalRequestApiResource { // ── GET /v1/clerk/share-withdrawal-requests/{id} ── Full detail ───────────── + /** + * Unlike list()/myRequests(), this single-record detail also carries the client's compliance + * summary (KYC status, Risk Category, open AML alert count) — so the review screen can show it + * permanently on the page, not only inside the on-demand /validate popup. + */ @GET @Path("/{id}") @Produces(MediaType.APPLICATION_JSON) public String getById(@PathParam("id") final Long id) { - return gson.toJson(loadRecord(id)); + final Map record = loadRecord(id); + final Map enriched = new java.util.LinkedHashMap<>(); + for (final Map.Entry entry : record.entrySet()) { + enriched.put(String.valueOf(entry.getKey()), entry.getValue()); + } + final Object clientIdObj = record.get("client_id"); + final Long clientId = clientIdObj instanceof Number ? ((Number) clientIdObj).longValue() : null; + enriched.putAll(loadClientComplianceSummary(clientId)); + return gson.toJson(enriched); + } + + private Map loadClientComplianceSummary(final Long clientId) { + final Map summary = new java.util.LinkedHashMap<>(); + if (clientId == null) { + return summary; + } + final Map kycRow = jdbcTemplate.queryForList(""" + SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc + WHERE client_id = ? ORDER BY id DESC LIMIT 1 + """, clientId).stream().findFirst().orElse(null); + final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null; + final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null ? ((Number) kycRow.get("riskCategory")).intValue() + : null; + final Long openAlerts = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId); + + summary.put("client_kyc_status_label", kycStatus != null ? kycStatusLabel(kycStatus) : "No KYC record"); + summary.put("client_kyc_verified", kycStatus != null && kycStatus == 300); + summary.put("client_risk_category_label", riskCategory != null ? riskCategoryLabel(riskCategory) : "Not classified"); + summary.put("client_risk_high", riskCategory != null && riskCategory == 3); + summary.put("client_open_aml_alerts", openAlerts != null ? openAlerts : 0L); + return summary; } // ── POST /v1/clerk/share-withdrawal-requests/{id}/validate ── Manager runs live checks ── @@ -256,20 +292,22 @@ public class ClerkShareWithdrawalRequestApiResource { /** * Re-runs every check against the CURRENT live state (not the snapshot taken at submit time), so * the checker can catch things that changed since submission — a share/savings account that got - * closed, a payment type that got deactivated, or (the exact bug class this session ran into) a - * savings product's GL Fund Source mapping for this payment type going missing, which would - * silently post the settlement leg to the default Cash at Vault account instead of Share Sundry. + * closed, a payment type that got deactivated, a client whose KYC lapsed or who now has an open + * AML alert, an active loan secured by these very shares ({@code m_share_account.linked_loan_id} + * — this bank supports loan-against-shares), that loan's tenure not yet being complete, this + * client currently guaranteeing someone else's active loan, or (the exact bug class this session + * ran into) a savings product's GL Fund Source mapping for this payment type going missing, which + * would silently post the settlement leg to the default Cash at Vault account instead of Share + * Sundry. */ @POST @Path("/{id}/validate") @Produces(MediaType.APPLICATION_JSON) public String validate(@PathParam("id") final Long id) { + final AppUser currentUser = context.authenticatedUser(); final Map record = loadRecord(id); final List> checks = new ArrayList<>(); - final String status = (String) record.get("status"); - checks.add(checkResult("Request status is PENDING", "PENDING".equals(status), String.valueOf(status))); - final String resolutionNumber = (String) record.get("resolution_number"); checks.add(checkResult("Board Resolution Number provided", resolutionNumber != null && !resolutionNumber.isBlank(), resolutionNumber == null || resolutionNumber.isBlank() ? "Missing" : resolutionNumber)); @@ -282,7 +320,8 @@ public class ClerkShareWithdrawalRequestApiResource { final Long requestedShares = ((Number) record.get("requested_shares")).longValue(); final Map shareAccountRow = jdbcTemplate.queryForList(""" - SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, savings_account_id AS savingsAccountId + SELECT total_approved_shares AS totalApprovedShares, status_enum AS statusEnum, + savings_account_id AS savingsAccountId, client_id AS clientId, linked_loan_id AS linkedLoanId FROM m_share_account WHERE id = ? """, shareAccountId).stream().findFirst().orElse(null); @@ -295,13 +334,14 @@ public class ClerkShareWithdrawalRequestApiResource { final int shareAccountStatus = shareAccountRow != null && shareAccountRow.get("statusEnum") != null ? ((Number) shareAccountRow.get("statusEnum")).intValue() : -1; - checks.add(checkResult("Share account is Active", shareAccountStatus == 300, "Status code: " + shareAccountStatus)); + checks.add(checkResult("Share account is Active", shareAccountStatus == 300, shareStatusLabel(shareAccountStatus))); final Long savingsAccountId = shareAccountRow != null && shareAccountRow.get("savingsAccountId") != null ? ((Number) shareAccountRow.get("savingsAccountId")).longValue() : null; - checks.add(checkResult("Linked savings account confirmed", savingsAccountId != null, - savingsAccountId != null ? "Savings account id " + savingsAccountId : "No linked savings account configured")); + final Long clientId = shareAccountRow != null && shareAccountRow.get("clientId") != null + ? ((Number) shareAccountRow.get("clientId")).longValue() + : null; Integer savingsStatus = null; Long savingsProductId = null; @@ -315,26 +355,83 @@ public class ClerkShareWithdrawalRequestApiResource { savingsProductId = savingsRow != null && savingsRow.get("productId") != null ? ((Number) savingsRow.get("productId")).longValue() : null; } - checks.add(checkResult("Linked savings account is Active", savingsStatus != null && savingsStatus == 300, - savingsStatus != null ? "Status code: " + savingsStatus : "Not found")); + checks.add(checkResult("Linked savings account is Active", savingsAccountId != null && savingsStatus != null && savingsStatus == 300, + savingsAccountId == null ? "No linked savings account configured" + : "Account #" + savingsAccountId + " — " + savingsStatusLabel(savingsStatus))); + + // Loan / Tenure / Guarantor checks — this bank links shares to a loan (m_share_account.linked_loan_id) + // when shares secure a loan against shares, so a withdrawal must not weaken that security while the + // loan is still outstanding, and separately, releasing shares to a client currently on the hook as a + // guarantor for someone else's active loan deserves the same scrutiny. + final Long linkedLoanId = shareAccountRow != null && shareAccountRow.get("linkedLoanId") != null + ? ((Number) shareAccountRow.get("linkedLoanId")).longValue() + : null; + Map linkedLoanRow = null; + if (linkedLoanId != null) { + linkedLoanRow = jdbcTemplate.queryForList(""" + SELECT account_no AS accountNo, loan_status_id AS statusId, expected_maturedon_date AS expectedMaturedOnDate + FROM m_loan WHERE id = ? + """, linkedLoanId).stream().findFirst().orElse(null); + } + final Integer linkedLoanStatus = linkedLoanRow != null && linkedLoanRow.get("statusId") != null + ? ((Number) linkedLoanRow.get("statusId")).intValue() + : null; + final boolean linkedLoanOutstanding = linkedLoanStatus != null && isLoanStatusOutstanding(linkedLoanStatus); + final String loanCheckDetail; + if (linkedLoanId == null) { + loanCheckDetail = "No loan linked to this share account"; + } else if (linkedLoanRow == null) { + loanCheckDetail = "Loan #" + linkedLoanId + " — record not found"; + } else { + loanCheckDetail = "Loan " + linkedLoanRow.get("accountNo") + " — " + loanStatusLabel(linkedLoanStatus); + } + checks.add(checkResult("Loan Check — no active loan secured by these shares", linkedLoanId == null || !linkedLoanOutstanding, + loanCheckDetail)); + + boolean tenureOk = true; + String tenureDetail = "No linked loan"; + if (linkedLoanId != null && linkedLoanOutstanding && linkedLoanRow != null) { + final Object maturedOnObj = linkedLoanRow.get("expectedMaturedOnDate"); + if (maturedOnObj instanceof java.sql.Date maturedOnDate) { + final boolean matured = !maturedOnDate.toLocalDate() + .isAfter(LocalDate.now(DateUtils.getDateTimeZoneOfTenant())); + tenureOk = matured; + tenureDetail = "Linked loan term ends " + maturedOnDate.toLocalDate() + (matured ? " — completed" : " — not yet completed"); + } else { + tenureOk = false; + tenureDetail = "Linked loan is active with no expected maturity date on record"; + } + } + checks.add(checkResult("Tenure Check — linked loan term completed", tenureOk, tenureDetail)); + + if (clientId != null) { + final Long guarantorActiveLoans = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) FROM m_guarantor g JOIN m_loan l ON l.id = g.loan_id + WHERE g.entity_id = ? AND g.type_enum = 1 AND g.is_active = true + AND l.loan_status_id IN (300, 303, 304, 700) + """, Long.class, clientId); + checks.add(checkResult("Guarantor Check — not guaranteeing any active loan", + guarantorActiveLoans == null || guarantorActiveLoans == 0, + guarantorActiveLoans != null && guarantorActiveLoans > 0 ? "Guarantor on " + guarantorActiveLoans + " active loan(s)" + : "None")); + } final Object settlementPaymentTypeIdObj = record.get("settlement_payment_type_id"); final Long settlementPaymentTypeId = settlementPaymentTypeIdObj instanceof Number ? ((Number) settlementPaymentTypeIdObj).longValue() : null; - final Object settlementPaymentTypeName = record.get("settlement_payment_type_name"); - checks.add(checkResult("Settlement Payment Type selected", settlementPaymentTypeId != null, - settlementPaymentTypeName != null ? settlementPaymentTypeName.toString() : "Missing")); - boolean paymentTypeNonCash = false; + String paymentTypeDetail = "Missing"; if (settlementPaymentTypeId != null) { final Map pt = jdbcTemplate - .queryForList("SELECT is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream() + .queryForList("SELECT value, is_cash_payment FROM m_payment_type WHERE id = ?", settlementPaymentTypeId).stream() .findFirst().orElse(null); - paymentTypeNonCash = pt != null && !Boolean.TRUE.equals(pt.get("is_cash_payment")); + if (pt != null) { + paymentTypeNonCash = !Boolean.TRUE.equals(pt.get("is_cash_payment")); + paymentTypeDetail = pt.get("value") + (paymentTypeNonCash ? "" : " (cash — not allowed)"); + } } - checks.add(checkResult("Settlement Payment Type is non-cash", paymentTypeNonCash, - paymentTypeNonCash ? "Confirmed non-cash" : "Cash payment type or not found — settlement would be rejected")); + checks.add(checkResult("Settlement Payment Type is valid (non-cash)", paymentTypeNonCash, paymentTypeDetail)); boolean glMappingOk = false; String glMappingDetail = "Linked savings account or settlement payment type missing — cannot check"; @@ -346,10 +443,37 @@ public class ClerkShareWithdrawalRequestApiResource { """, savingsProductId, settlementPaymentTypeId).stream().findFirst().orElse(null); glMappingOk = mapping != null; glMappingDetail = mapping != null ? "Fund source mapped to: " + mapping.get("glName") - : "No GL Fund Source override configured for this Payment Type — settlement would silently post to the default Cash at Vault account instead of Share Sundry"; + : "No override configured — settlement would silently post to the default Cash at Vault account instead of Share Sundry"; } checks.add(checkResult("GL Fund Source mapping configured for this Payment Type", glMappingOk, glMappingDetail)); + if (clientId != null) { + final Map kycRow = jdbcTemplate.queryForList(""" + SELECT kyc_status AS kycStatus, risk_category AS riskCategory FROM m_client_kyc + WHERE client_id = ? ORDER BY id DESC LIMIT 1 + """, clientId).stream().findFirst().orElse(null); + + final Integer kycStatus = kycRow != null && kycRow.get("kycStatus") != null ? ((Number) kycRow.get("kycStatus")).intValue() : null; + checks.add(checkResult("Client KYC is Verified", kycStatus != null && kycStatus == 300, + kycStatus == null ? "No KYC record found" : kycStatusLabel(kycStatus))); + + final Integer riskCategory = kycRow != null && kycRow.get("riskCategory") != null + ? ((Number) kycRow.get("riskCategory")).intValue() + : null; + checks.add(checkResult("Client Risk Category is not High", riskCategory == null || riskCategory != 3, + riskCategory == null ? "Not classified" : riskCategoryLabel(riskCategory))); + + final Long openAlerts = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM m_aml_alert WHERE client_id = ? AND status IN ('RAISED','ESCALATED')", Long.class, clientId); + checks.add(checkResult("No open AML alerts for this client", openAlerts == null || openAlerts == 0, + openAlerts != null && openAlerts > 0 ? openAlerts + " open alert(s)" : "None")); + } + + final Long makerId = ((Number) record.get("clerk_id")).longValue(); + final boolean makerCheckerOk = !makerId.equals(currentUser.getId()); + checks.add(checkResult("Maker and Checker are different users", makerCheckerOk, + makerCheckerOk ? "Confirmed different users" : "You submitted this request — another manager must approve it")); + final boolean allPassed = checks.stream().allMatch(c -> Boolean.TRUE.equals(c.get("pass"))); final JsonObject res = new JsonObject(); res.addProperty("allPassed", allPassed); @@ -365,6 +489,75 @@ public class ClerkShareWithdrawalRequestApiResource { return result; } + private boolean isLoanStatusOutstanding(final int loanStatusId) { + return loanStatusId == 300 || loanStatusId == 303 || loanStatusId == 304 || loanStatusId == 700; + } + + private String loanStatusLabel(final int code) { + return switch (code) { + case 100 -> "Submitted and Pending Approval"; + case 200 -> "Approved"; + case 300 -> "Active"; + case 303 -> "Transfer in Progress"; + case 304 -> "Transfer on Hold"; + case 400 -> "Withdrawn by Client"; + case 500 -> "Rejected"; + case 600 -> "Closed (Obligations Met)"; + case 601 -> "Closed (Written Off)"; + case 602 -> "Closed (Rescheduled)"; + case 700 -> "Overpaid"; + default -> "Unknown (" + code + ")"; + }; + } + + private String shareStatusLabel(final int code) { + return switch (code) { + case 100 -> "Submitted and Pending Approval"; + case 200 -> "Approved"; + case 300 -> "Active"; + case 500 -> "Rejected"; + case 600 -> "Closed"; + default -> "Unknown (" + code + ")"; + }; + } + + private String savingsStatusLabel(final int code) { + return switch (code) { + case 100 -> "Submitted and Pending Approval"; + case 200 -> "Approved"; + case 300 -> "Active"; + case 303 -> "Transfer in Progress"; + case 304 -> "Transfer on Hold"; + case 400 -> "Withdrawn by Applicant"; + case 500 -> "Rejected"; + case 600 -> "Closed"; + case 700 -> "Pre-mature Closure"; + case 800 -> "Matured"; + case 900 -> "Terminated"; + default -> "Unknown (" + code + ")"; + }; + } + + private String kycStatusLabel(final int code) { + return switch (code) { + case 100 -> "Pending"; + case 200 -> "In Progress"; + case 300 -> "Verified"; + case 400 -> "Rejected"; + case 500 -> "Expired"; + default -> "Unknown (" + code + ")"; + }; + } + + private String riskCategoryLabel(final int code) { + return switch (code) { + case 1 -> "Low"; + case 2 -> "Medium"; + case 3 -> "High"; + default -> "Unknown (" + code + ")"; + }; + } + // ── POST /v1/clerk/share-withdrawal-requests/{id}/approve ── Manager approves ── @POST