diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java index 188ca999d..a699066e3 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/api/LoanOriginationPipelineApiResource.java @@ -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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java index 5b23c1a50..3223f98e2 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationPipelineService.java @@ -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 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 diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java index e056d2809..7b67467bf 100644 --- a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/LoanOriginationStageService.java @@ -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 STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION", + private static final List 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 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 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 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.");