feat(shares): add compliance and loan/guarantor/tenure checks to validation
The /validate endpoint now re-checks live state that matters for a real UCB share redemption: Client KYC verified, Risk Category not High, no open AML alerts, and — since this bank supports loan-against-shares via m_share_account.linked_loan_id — a Loan Check (no active loan secured by these shares), Tenure Check (linked loan's term completed), and Guarantor Check (client not guaranteeing someone else's active loan). Dropped the redundant "status is PENDING" check and replaced raw status_enum codes with human-readable labels throughout. getById() also now returns a client compliance summary so the review screen can show it without running the popup validation. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+215
-22
@@ -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<String, Object> 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<String, Object> loadClientComplianceSummary(final Long clientId) {
|
||||
final Map<String, Object> summary = new java.util.LinkedHashMap<>();
|
||||
if (clientId == null) {
|
||||
return summary;
|
||||
}
|
||||
final Map<String, Object> 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<Map<String, Object>> 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<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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
|
||||
|
||||
Reference in New Issue
Block a user