feat(loan-origination): deposit-selection stage and maker-checker on approval decisions
Fineract Build & Cucumber tests (without E2E tests) / verify (main, build-core) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (main, cucumber) (push) Canceled after 0s
Fineract Build & Cucumber tests (without E2E tests) / verify (progressive-loan, build-progressive-loan) (push) Canceled after 0s
Fineract Docker Builds / build (false, docker-compose-postgresql.yml, postgresql) (push) Canceled after 0s
Fineract Docker Builds / build (true, docker-compose.yml, mariadb) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 1 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 10 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 2 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 3 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 4 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 5 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 6 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 7 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 8 of 10) (push) Canceled after 0s
Fineract E2E Tests / E2E Tests (Shard 9 of 10) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MariaDB / test (test-core-5) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-5) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-4) (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Documentation build / build (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-2) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-3) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - MySQL / test (test-core-4) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-1) (push) Canceled after 0s
Fineract Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (push) Canceled after 0s
Fineract Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s

Add a deposit-selection-complete endpoint for loans against a deposit
(skips the credit bureau stage once a saved selection covers the
requested amount/tenure), and track who submits, approves and withdraws
each approval decision instead of a single unattributed decision call.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 18:23:26 +05:30
co-authored by Claude Sonnet 5
parent e95e981bcd
commit ceb8e97901
3 changed files with 309 additions and 41 deletions
@@ -137,6 +137,19 @@ public class LoanOriginationPipelineApiResource {
// the updated application, so the client replaces its copy rather than guessing the result.
// ─────────────────────────────────────────────────────────────────────────
@POST
@Path("{applicationNo}/deposit-selection-complete")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Complete Deposit Selection & Eligibility",
description = "Loan against a deposit only. Requires a saved deposit selection that covers the requested amount "
+ "and tenure; the credit bureau stage is skipped.")
public String completeDepositSelection(@PathParam("applicationNo") final String applicationNo,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.completeDepositSelection(applicationNo, apiRequestBodyAsJson);
}
@POST
@Path("{applicationNo}/kyc-complete")
@Consumes(MediaType.APPLICATION_JSON)
@@ -239,8 +252,29 @@ public class LoanOriginationPipelineApiResource {
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
final String apiRequestBodyAsJson) {
this.context.authenticatedUser();
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson,
this.context.authenticatedUser().getUsername());
}
@POST
@Path("{applicationNo}/approvals/{field}/submit")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Send a stage for approval",
description = "Maker step. The stage's checklist must be complete; a different user then approves or rejects it.")
public String submitApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
final String apiRequestBodyAsJson) {
return this.stageService.submitApproval(applicationNo, field, apiRequestBodyAsJson,
this.context.authenticatedUser().getUsername());
}
@POST
@Path("{applicationNo}/approvals/{field}/withdraw")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
@Operation(summary = "Withdraw an approval request", description = "Only the user who sent the request can withdraw it.")
public String withdrawApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field) {
return this.stageService.withdrawApproval(applicationNo, field, this.context.authenticatedUser().getUsername());
}
@POST
@@ -185,9 +185,29 @@ public class LoanOriginationPipelineService {
*/
@Transactional
public String save(final String applicationNo, final String requestBodyAsJson) {
// Once a stage is sent for approval nothing on the application may change until it is decided or withdrawn,
// so a full-object save is ignored and the stored application is returned as it is.
if (hasPendingApprovalRequest(loadApplication(applicationNo))) {
return findOneAsJson(applicationNo);
}
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
}
private boolean hasPendingApprovalRequest(final JsonObject app) {
final JsonElement requests = app.get("approvalRequests");
if (requests == null || !requests.isJsonObject()) {
return false;
}
for (final java.util.Map.Entry<String, JsonElement> entry : requests.getAsJsonObject().entrySet()) {
final JsonElement request = entry.getValue();
if (request.isJsonObject() && request.getAsJsonObject().has("status")
&& "PENDING".equals(request.getAsJsonObject().get("status").getAsString())) {
return true;
}
}
return false;
}
/** Writes a whole application object back. Shared by the full-object save and every per-stage
* endpoint, so both paths persist through exactly the same code. */
@Transactional
@@ -36,6 +36,7 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.Arrays;
import java.util.List;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
import org.apache.fineract.infrastructure.core.service.DateUtils;
@@ -71,15 +72,34 @@ public class LoanOriginationStageService {
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION",
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "DEPOSIT_SELECTION", "CIC_CHECK", "FIELD_INVESTIGATION",
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
"POST_CHECK", "DISBURSED");
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
"shopVerification");
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security. */
private static final Set<String> DEPOSIT_PRODUCT_CODES = Set.of("FD", "RD", "DS", "LI");
private final LoanOriginationPipelineService pipelineService;
/** A loan above the product's limit on the deposit (85%) is allowed only as a recorded officer's discretion with a reason. */
private void requireWithinLimitOrException(final JsonObject app, final JsonObject selection) {
final double value = selection.has("securityValue") ? selection.get("securityValue").getAsDouble() : 0;
final double limitPct = selection.has("limitPct") ? selection.get("limitPct").getAsDouble() : 85;
final double requested = intOr(app, "requestedAmount", 0);
if (requested > value * limitPct / 100 + 0.5) {
require(bool(selection, "over85") && !strOr(selection, "over85Reason", "").isBlank(),
"The loan is above " + (int) limitPct + "% of the deposit; record the reason for sanctioning above the limit.");
}
}
private boolean isDepositLoan(final JsonObject app) {
final String code = str(app, "productCode");
return code != null && DEPOSIT_PRODUCT_CODES.contains(code);
}
// ── KYC & Documents ──────────────────────────────────────────────────────
@Transactional
@@ -90,16 +110,51 @@ public class LoanOriginationStageService {
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
require(bool(app, "panVerified"), "PAN has not been verified.");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
final boolean depositLoan = isDepositLoan(app);
if (!depositLoan) {
// For a loan against a deposit these are optional: the deposit is the security.
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
}
for (final JsonElement element : arr(app, "productDocuments")) {
final JsonObject doc = element.getAsJsonObject();
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
}
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
if (!depositLoan) {
// Deposit selection only exists for a loan against a deposit; every other product passes straight through.
advance(app, actor(input), "Deposit Selection Not Required", "Not a loan against a deposit.");
}
return pipelineService.storeApplication(applicationNo, app);
}
// ── Deposit Selection & Eligibility (loan against FD / RD / DDS / LIC) ───
@Transactional
public String completeDepositSelection(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "DEPOSIT_SELECTION");
require(isDepositLoan(app), "Deposit selection applies only to a loan against a deposit.");
final JsonObject selection = obj(app, "depositSelection");
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
require(!arr(selection, "accountIds").isEmpty(), "Select at least one deposit.");
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
"The requested amount is more than the eligible amount on the selected deposits.");
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
"The tenure runs past the maturity of the selected deposits.");
requireWithinLimitOrException(app, selection);
advance(app, actor(input), "Deposit Selection Complete",
strOr(input, "remarks", "Deposit selected and eligibility confirmed."));
// No credit bureau check on a loan against a deposit: the stage is recorded as not required and skipped.
// The deposit is the security: no credit bureau check and no field investigation. Every other stage stays.
advance(app, actor(input), "CIC Not Required", "Loan against a deposit - no credit bureau check.");
advance(app, actor(input), "Field Investigation Not Required", "Loan against a deposit - no field investigation.");
return pipelineService.storeApplication(applicationNo, app);
}
@@ -186,7 +241,8 @@ public class LoanOriginationStageService {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "PERSONAL_DISCUSSION");
require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
// A loan against a deposit has no meeting to schedule: the loan plan is reviewed instead.
require(isDepositLoan(app) || bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
app.addProperty("discrepancyType", "NONE");
@@ -247,41 +303,144 @@ public class LoanOriginationStageService {
*
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
*/
@Transactional
public String decideApproval(final String applicationNo, final String field, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
final String decision = strOr(input, "decision", "APPROVED");
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
final String expectedStage = switch (field) {
private String approvalStage(final String field) {
return switch (field) {
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
case "riskApproval" -> "RISK_APPROVAL";
case "legalApproval" -> "LEGAL_APPROVAL";
case "postCheckApproval" -> "POST_CHECK";
default -> throw badRequest("Unknown approval stage: " + field);
};
}
private void checkApprovalPrerequisites(final JsonObject app, final String field, final JsonObject input) {
switch (field) {
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
case "riskApproval" -> requireRiskChecklistReady(app);
case "legalApproval" -> requireLegalChecklistDone(app);
case "postCheckApproval" -> requirePostCheckSignatures(app);
default -> throw badRequest("Unknown approval stage: " + field);
}
}
private JsonObject pendingRequest(final JsonObject app, final String field) {
final JsonObject requests = obj(app, "approvalRequests");
final JsonObject request = requests == null ? null : obj(requests, field);
return request != null && "PENDING".equals(str(request, "status")) ? request : null;
}
/**
* Maker step: the stage's checklist is complete, and the decision is sent to someone else. Nothing advances until
* a different user approves it.
*/
@Transactional
public String submitApproval(final String applicationNo, final String field, final String body, final String user) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
final String expectedStage = approvalStage(field);
requireStage(app, expectedStage);
require(pendingRequest(app, field) == null, "This stage has already been sent for approval.");
final String requested = strOr(input, "decision", "APPROVED");
require(List.of("APPROVED", "EXCEPTION_APPROVED").contains(requested), "Unknown decision: " + requested);
checkApprovalPrerequisites(app, field, input);
final String remarks = strOr(input, "remarks", "Sent for approval.");
final JsonObject request = new JsonObject();
request.addProperty("status", "PENDING");
request.addProperty("submittedBy", user);
request.addProperty("submittedOn", today());
request.addProperty("requestedDecision", requested);
request.addProperty("remarks", remarks);
request.addProperty("resolutionNo", strOr(input, "resolutionNo", ""));
request.addProperty("resolutionDate", strOr(input, "resolutionDate", ""));
// What the sender had verified when sending: kept as it was, for the approver to review.
if (input.has("checklist") && input.get("checklist").isJsonArray()) {
request.add("checklist", input.getAsJsonArray("checklist"));
}
objOrCreate(app, "approvalRequests").add(field, request);
appendHistory(app, expectedStage, user, "Sent for Approval", remarks);
return pipelineService.storeApplication(applicationNo, app);
}
@Transactional
public String withdrawApproval(final String applicationNo, final String field, final String user) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final String expectedStage = approvalStage(field);
requireStage(app, expectedStage);
final JsonObject request = pendingRequest(app, field);
require(request != null, "There is no pending approval request on this stage.");
require(user.equalsIgnoreCase(str(request, "submittedBy")), "Only the person who sent the request can withdraw it.");
request.addProperty("status", "WITHDRAWN");
appendHistory(app, expectedStage, user, "Approval Request Withdrawn", "Withdrawn by " + user + ".");
return pipelineService.storeApplication(applicationNo, app);
}
/**
* The approver has to have ticked every row the sender verified, and the sender must not have left any row
* unverified. Requests sent before the checklist was recorded are not held to this.
*/
private void requireCheckerVerified(final JsonObject request, final JsonObject input) {
if (!request.has("checklist") || !request.get("checklist").isJsonArray()) {
return;
}
final java.util.Set<String> ticked = new java.util.HashSet<>();
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
for (final JsonElement element : input.getAsJsonArray("checkerChecklist")) {
final JsonObject row = element.getAsJsonObject();
if (bool(row, "ok")) {
ticked.add(str(row, "label"));
}
}
}
for (final JsonElement element : request.getAsJsonArray("checklist")) {
final JsonObject row = element.getAsJsonObject();
final String label = strOr(row, "label", "item");
require(bool(row, "ok"), "Cannot approve: the sender did not verify \"" + label + "\".");
require(ticked.contains(label), "Tick every row you have verified before approving: \"" + label + "\".");
}
}
/** Checker step: a different user approves or rejects what was sent. */
@Transactional
public String decideApproval(final String applicationNo, final String field, final String body, final String user) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
String decision = strOr(input, "decision", "APPROVED");
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
final String expectedStage = approvalStage(field);
requireStage(app, expectedStage);
final JsonObject request = pendingRequest(app, field);
require(request != null, "This stage has not been sent for approval yet.");
require(!user.equalsIgnoreCase(str(request, "submittedBy")),
"The person who sent this for approval cannot approve or reject it. Ask another user.");
if (!"REJECTED".equals(decision)) {
decision = strOr(request, "requestedDecision", "APPROVED");
}
if (!"REJECTED".equals(decision)) {
requireCheckerVerified(request, input);
}
// Prerequisites are only enforced for a positive decision: a rejection has to remain possible
// at any point, and blocking it on an incomplete checklist would trap the application.
final JsonObject resolution = new JsonObject();
resolution.addProperty("resolutionNo", strOr(request, "resolutionNo", ""));
resolution.addProperty("resolutionDate", strOr(request, "resolutionDate", ""));
if (!"REJECTED".equals(decision)) {
switch (field) {
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
case "riskApproval" -> requireRiskChecklistReady(app);
case "legalApproval" -> requireLegalChecklistDone(app);
case "postCheckApproval" -> requirePostCheckSignatures(app);
default -> throw badRequest("Unknown approval stage: " + field);
}
checkApprovalPrerequisites(app, field, resolution);
}
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
// The resolution number and date are what the sanction letter later quotes, so they are
// recorded on the application and restated in the audit trail.
final String resolutionNo = strOr(input, "resolutionNo", "");
final String resolutionDate = strOr(input, "resolutionDate", "");
final String resolutionNo = strOr(resolution, "resolutionNo", "");
final String resolutionDate = strOr(resolution, "resolutionDate", "");
app.addProperty("committeeResolutionNo", resolutionNo);
app.addProperty("committeeResolutionDate", resolutionDate);
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
@@ -289,22 +448,27 @@ public class LoanOriginationStageService {
final JsonObject approval = objOrCreate(app, field);
approval.addProperty("decision", decision);
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
approval.addProperty("approverName", user);
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
approval.addProperty("date", today());
approval.addProperty("remarks", remarks);
if (input.has("checkerChecklist") && input.get("checkerChecklist").isJsonArray()) {
request.add("checkerChecklist", input.getAsJsonArray("checkerChecklist"));
}
request.addProperty("status", decision);
request.addProperty("decidedBy", user);
request.addProperty("decidedOn", today());
if ("REJECTED".equals(decision)) {
app.addProperty("overallStatus", "REJECTED");
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
appendHistory(app, str(app, "currentStage"), user, "Rejected", remarks);
} else {
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
advance(app, user, "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
}
return pipelineService.storeApplication(applicationNo, app);
}
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
@Transactional
public String markSigned(final String applicationNo, final String body) {
final JsonObject app = pipelineService.loadApplication(applicationNo);
@@ -315,6 +479,11 @@ public class LoanOriginationStageService {
app.addProperty("customerSigningDate", today());
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
strOr(input, "remarks", "Loan agreement signed by the customer."));
if (isDepositLoan(app)) {
// Recovery of a loan against a deposit is by set-off against the lien (policy 9.5), so no security cheque / ACH mandate.
advance(app, actor(input), "Cheque & ACH Not Required",
"Loan against a deposit - recovery by set-off against the lien.");
}
return pipelineService.storeApplication(applicationNo, app);
}
@@ -323,6 +492,12 @@ public class LoanOriginationStageService {
final JsonObject app = pipelineService.loadApplication(applicationNo);
final JsonObject input = parseObject(body);
requireStage(app, "CHEQUE_ACH");
if (isDepositLoan(app)) {
// An application that was already waiting at this stage before it was made optional for deposit loans.
advance(app, actor(input), "Cheque & ACH Not Required",
"Loan against a deposit - recovery by set-off against the lien.");
return pipelineService.storeApplication(applicationNo, app);
}
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
@@ -350,6 +525,14 @@ public class LoanOriginationStageService {
"Cannot disburse: " + field + " is not approved.");
}
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
if (isDepositLoan(app)) {
final JsonObject selection = obj(app, "depositSelection");
// The product decides whether a lien is required (Product Creation > Security & Lien); it defaults to required.
final boolean lienRequired = selection == null || !selection.has("lienRequired") || bool(selection, "lienRequired");
final JsonObject lien = obj(app, "lien");
require(!lienRequired || (lien != null && bool(lien, "marked")),
"Cannot disburse: the lien on the pledged deposit has not been marked.");
}
final String mode = strOr(input, "disbursementMode", "Account Credit");
app.addProperty("disbursementDate", today());
@@ -390,29 +573,50 @@ public class LoanOriginationStageService {
}
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
final boolean depositLoan = isDepositLoan(app);
if (!depositLoan) {
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
}
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
if (bool(app, "secured")) {
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
}
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
require(bool(app, "panVerified"), "PAN has not been verified.");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
requireFieldInvestigationComplete(app);
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
if (!depositLoan) {
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
requireFieldInvestigationComplete(app);
}
require(depositLoan || bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
}
private void requireRiskChecklistReady(final JsonObject app) {
requireCheckVerified(app, "addressVerification", "Address verification");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
final boolean depositLoan = isDepositLoan(app);
if (depositLoan) {
// What Risk confirms on a loan against a deposit: KYC done, and the deposit still covers the loan.
require(bool(app, "aadhaarVerified") && bool(app, "panVerified"), "KYC (Aadhaar and PAN) is not verified.");
final JsonObject selection = obj(app, "depositSelection");
require(selection != null && bool(selection, "saved"), "No deposit has been selected and saved for this application.");
require(intOr(selection, "eligibleAmount", 0) >= intOr(app, "requestedAmount", 0),
"The loan is more than the eligible amount on the pledged deposit.");
require(intOr(app, "tenureMonths", 0) <= intOr(selection, "maxTenure", 0),
"The tenure runs past the maturity of the pledged deposit.");
requireWithinLimitOrException(app, selection);
}
if (!depositLoan) {
requireCheckVerified(app, "addressVerification", "Address verification");
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
}
require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
if (bool(app, "secured")) {
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
}
requireFieldInvestigationComplete(app);
if (!depositLoan) {
requireFieldInvestigationComplete(app);
}
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
if (bool(app, "guarantorRequired")) {
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
@@ -424,21 +628,31 @@ public class LoanOriginationStageService {
}
private void requireLegalChecklistDone(final JsonObject app) {
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
// A loan against a deposit needs only the stamp duty at this stage (policy 9.5): no fee or file-filing proof.
final boolean depositLoan = isDepositLoan(app);
require(depositLoan || bool(app, "feesAndAccountOpened"), "The processing fee has not been collected.");
if (bool(app, "secured")) {
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
}
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
require(bool(app, "fileFilingDone"), "File filing is not complete.");
require(depositLoan || bool(app, "fileFilingDone"), "File filing is not complete.");
for (final JsonElement element : arr(app, "legalDocuments")) {
final JsonObject doc = element.getAsJsonObject();
if (depositLoan && !bool(doc, "custom")
&& ("feeReceipt".equals(str(doc, "key")) || "fileFilingAck".equals(str(doc, "key")))) {
continue;
}
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
}
}
private void requirePostCheckSignatures(final JsonObject app) {
require(bool(app, "signedByAgent"), "The agent has not signed off.");
require(bool(app, "signedByManager"), "The manager has not signed off.");
if (isDepositLoan(app)) {
// Committee, risk and legal already approved through their own maker-checker stages.
return;
}
require(bool(app, "signedByAgent"), "The agent has not signed off.");
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");