From dfb22a18984ba43db618232c1ca91c5cd1175ccf Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Fri, 30 Oct 2026 15:28:28 +0530 Subject: [PATCH] feat(loan-origination): priority sector classification at KYC, deposit-loan sanction without checklists, lien release - KYC completion now requires the loan purpose, the priority sector decision (with its category) and the weaker section decision (with its type), checked on the server. - Deposit loans: no CERSAI / property / previous-loan-declaration requirement at sanction, even for an application created before its product was known to be a deposit product. - Priority sector categories and weaker section types are seeded as Codes (PrioritySectorCategory, WeakerSectionType) so they can be maintained in Admin. - DdsLienReleaseService lifts the DDS lien when the loan it secures ends. Co-Authored-By: Claude Sonnet 5.5 --- .../service/DdsLienReleaseService.java | 59 +++++ .../LoanOriginationPipelineService.java | 35 ++- .../service/LoanOriginationStageService.java | 235 ++++++++++++++++-- .../db/changelog/tenant/changelog-tenant.xml | 2 + ...608_seed_priority_sector_category_code.xml | 54 ++++ .../0609_seed_weaker_section_type_code.xml | 59 +++++ 6 files changed, 420 insertions(+), 24 deletions(-) create mode 100644 fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/DdsLienReleaseService.java create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_seed_priority_sector_category_code.xml create mode 100644 fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_seed_weaker_section_type_code.xml diff --git a/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/DdsLienReleaseService.java b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/DdsLienReleaseService.java new file mode 100644 index 000000000..e3484a2af --- /dev/null +++ b/fineract-provider/src/main/java/org/apache/fineract/portfolio/loanoriginationpipeline/service/DdsLienReleaseService.java @@ -0,0 +1,59 @@ +package org.apache.fineract.portfolio.loanoriginationpipeline.service; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.fineract.infrastructure.event.business.BusinessEventListener; +import org.apache.fineract.infrastructure.event.business.domain.loan.LoanStatusChangedBusinessEvent; +import org.apache.fineract.infrastructure.event.business.service.BusinessEventNotifierService; +import org.apache.fineract.portfolio.loanaccount.domain.Loan; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +/** + * A Daily Deposit Scheme account pledged for a loan is held with a lien whose reason names the loan + * ({@link #reasonFor}). The DDS module and the loan module are not connected, so nothing else would ever lift + * that hold: this lifts it when the loan stops being live (repaid, closed, written off, withdrawn). + * + *

+ * FD and RD liens need nothing like this: they are Fineract guarantor funding, which Fineract releases itself as the + * loan is repaid. + */ +@Component +@RequiredArgsConstructor +@Slf4j +public class DdsLienReleaseService { + + private static final String LIEN_PREFIX = "Held for loan "; + + private final BusinessEventNotifierService businessEventNotifierService; + private final JdbcTemplate jdbcTemplate; + + /** The text written on the DDS account's lien, and what is matched when the loan ends. */ + public static String reasonFor(final String loanAccountNo, final String applicationNo) { + return LIEN_PREFIX + loanAccountNo + " (" + applicationNo + ")"; + } + + @PostConstruct + public void addListeners() { + this.businessEventNotifierService.addPostBusinessEventListener(LoanStatusChangedBusinessEvent.class, + new ReleaseOnLoanEnd()); + } + + private final class ReleaseOnLoanEnd implements BusinessEventListener { + + @Override + public void onBusinessEvent(final LoanStatusChangedBusinessEvent event) { + final Loan loan = event.get(); + if (loan == null || loan.getStatus() == null || loan.getStatus().isActiveOrAwaitingApprovalOrDisbursal()) { + return; + } + final int released = jdbcTemplate.update( + "UPDATE m_dds_party SET lien = FALSE, lien_reason = NULL WHERE lien = TRUE AND lien_reason LIKE ?", + LIEN_PREFIX + loan.getAccountNumber() + " (%"); + if (released > 0) { + log.info("Released the lien on {} DDS account(s) held for loan {}", released, loan.getAccountNumber()); + } + } + } +} 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 3223f98e2..a705478d8 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,12 +185,43 @@ public class LoanOriginationPipelineService { */ @Transactional public String save(final String applicationNo, final String requestBodyAsJson) { + final JsonObject stored = loadApplication(applicationNo); // 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))) { + if (hasPendingApprovalRequest(stored)) { return findOneAsJson(applicationNo); } - return storeApplication(applicationNo, parseObject(requestBodyAsJson)); + // A disbursed or rejected application is final. A screen that was opened before that happened still holds the + // older copy, and saving it back would undo the disbursement (and lose the link to the loan account). + final String storedStatus = stringOrNull(stored, "overallStatus"); + if ("DISBURSED".equals(storedStatus) || "REJECTED".equals(storedStatus)) { + return findOneAsJson(applicationNo); + } + final JsonObject incoming = parseObject(requestBodyAsJson); + // A copy that is further back than what is stored is a stale screen: another user or a stage endpoint has moved the + // application on since it was opened. Its save is ignored and the stored application is returned, so the screen + // picks up the real state instead of overwriting it. + if (furthestStageOf(incoming) < furthestStageOf(stored)) { + return findOneAsJson(applicationNo); + } + // What only the server decides (approval decisions and requests, the loan account it was disbursed to) is never + // taken from the screen's copy. + for (final String key : SERVER_OWNED_FIELDS) { + if (stored.has(key)) { + incoming.add(key, stored.get(key).deepCopy()); + } else { + incoming.remove(key); + } + } + return storeApplication(applicationNo, incoming); + } + + private static final java.util.List SERVER_OWNED_FIELDS = java.util.List.of("creditCommitteeApproval", "riskApproval", + "legalApproval", "postCheckApproval", "disbursementApproval", "approvalRequests", "fineractLoanId", "fineractLoanAccountNo"); + + private int furthestStageOf(final JsonObject app) { + final JsonElement value = app.get("furthestStageIndex"); + return value != null && value.isJsonPrimitive() && value.getAsJsonPrimitive().isNumber() ? value.getAsInt() : 0; } private boolean hasPendingApprovalRequest(final JsonObject app) { 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 7b67467bf..a5f274377 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 @@ -40,6 +40,7 @@ import java.util.Set; import lombok.RequiredArgsConstructor; import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException; import org.apache.fineract.infrastructure.core.service.DateUtils; +import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -83,6 +84,7 @@ public class LoanOriginationStageService { private static final Set DEPOSIT_PRODUCT_CODES = Set.of("FD", "RD", "DS", "LI"); private final LoanOriginationPipelineService pipelineService; + private final JdbcTemplate jdbcTemplate; /** 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) { @@ -95,9 +97,34 @@ public class LoanOriginationStageService { } } + /** Product Creation sub types of the loans against a deposit or policy. */ + private static final Set DEPOSIT_SUB_TYPES = Set.of("fd", "rd", "dds", "lic"); + + /** + * A loan against a deposit is told by the product's own sub type (chosen in Product Creation), so a product made + * later under any short name (a second FD product, say) follows the deposit flow. The fixed short names stay as + * the answer for the original products. + */ private boolean isDepositLoan(final JsonObject app) { final String code = str(app, "productCode"); - return code != null && DEPOSIT_PRODUCT_CODES.contains(code); + if (code == null) { + return false; + } + return DEPOSIT_PRODUCT_CODES.contains(code) || DEPOSIT_SUB_TYPES.contains(productSubType(code)); + } + + private boolean isLicLoan(final JsonObject app) { + final String code = str(app, "productCode"); + return code != null && ("LI".equals(code) || "lic".equals(productSubType(code))); + } + + /** The sub type saved with the loan product of this short name; empty when the product has none. */ + private String productSubType(final String shortName) { + final List found = jdbcTemplate.queryForList( + "select e.product_type_sub_group from m_product_loan p join m_product_loan_advance_ext e on e.loan_product_id = p.id" + + " where p.short_name = ?", + String.class, shortName); + return found.isEmpty() || found.get(0) == null ? "" : found.get(0); } // ── KYC & Documents ────────────────────────────────────────────────────── @@ -110,6 +137,7 @@ public class LoanOriginationStageService { require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified."); require(bool(app, "panVerified"), "PAN has not been verified."); + requirePurposeAndPriorityClassification(app); final boolean depositLoan = isDepositLoan(app); if (!depositLoan) { // For a loan against a deposit these are optional: the deposit is the security. @@ -123,6 +151,10 @@ public class LoanOriginationStageService { require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document"))); } + if (app.has("guarantorsRequiredCount")) { + requireGuarantorsNamed(app); + } + 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. @@ -131,6 +163,25 @@ public class LoanOriginationStageService { return pipelineService.storeApplication(applicationNo, app); } + /** + * Every loan states what it is for and how it is classified under the RBI priority sector lending directions before + * KYC is complete: the purpose, the priority sector decision (with its category when it is priority sector) and + * whether the borrower is a weaker section (with its type when it is). + */ + private void requirePurposeAndPriorityClassification(final JsonObject app) { + require(!strOr(app, "loanPurpose", "").isBlank(), "The purpose of the loan has not been entered."); + final String priority = strOr(app, "pslClassified", ""); + require("yes".equals(priority) || "no".equals(priority), "The priority sector decision has not been made."); + if ("yes".equals(priority)) { + require(!strOr(app, "prioritySectorCategory", "").isBlank(), "The priority sector category has not been chosen."); + } + final String weaker = strOr(app, "weakerSection", ""); + require("yes".equals(weaker) || "no".equals(weaker), "Whether the borrower is a weaker section has not been decided."); + if ("yes".equals(weaker)) { + require(!strOr(app, "weakerSectionType", "").isBlank(), "The weaker section type has not been chosen."); + } + } + // ── Deposit Selection & Eligibility (loan against FD / RD / DDS / LIC) ─── @Transactional @@ -142,7 +193,15 @@ public class LoanOriginationStageService { 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."); + if (isLicLoan(app)) { + // A LIC policy is not a bank account: it is recorded on the application from the policy documents. + final JsonObject policy = obj(selection, "policy"); + require(policy != null && !strOr(policy, "policyNo", "").isBlank(), "The LIC policy number has not been recorded."); + require(intOr(policy, "surrenderValue", 0) > 0, "The surrender value of the policy has not been recorded."); + require(!strOr(policy, "maturityDate", "").isBlank(), "The maturity date of the policy has not been recorded."); + } else { + 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), @@ -309,6 +368,8 @@ public class LoanOriginationStageService { case "riskApproval" -> "RISK_APPROVAL"; case "legalApproval" -> "LEGAL_APPROVAL"; case "postCheckApproval" -> "POST_CHECK"; + // Sending the disbursement for approval: it is asked at the Disbursement stage and does not move the application on. + case "disbursementApproval" -> "DISBURSED"; default -> throw badRequest("Unknown approval stage: " + field); }; } @@ -319,10 +380,22 @@ public class LoanOriginationStageService { case "riskApproval" -> requireRiskChecklistReady(app); case "legalApproval" -> requireLegalChecklistDone(app); case "postCheckApproval" -> requirePostCheckSignatures(app); + case "disbursementApproval" -> requireSanctionApprovalsStanding(app); default -> throw badRequest("Unknown approval stage: " + field); } } + /** The four sanctioning approvals must all stand before a disbursement can be sent for approval. */ + private void requireSanctionApprovalsStanding(final JsonObject app) { + final List fields = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval") + : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval"); + for (final String field : fields) { + final JsonObject approval = obj(app, field); + final String decision = approval == null ? null : str(approval, "decision"); + require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision), "Cannot send for disbursement approval: " + field + " is not approved."); + } + } + private JsonObject pendingRequest(final JsonObject app, final String field) { final JsonObject requests = obj(app, "approvalRequests"); final JsonObject request = requests == null ? null : obj(requests, field); @@ -358,6 +431,13 @@ public class LoanOriginationStageService { if (input.has("checklist") && input.get("checklist").isJsonArray()) { request.add("checklist", input.getAsJsonArray("checklist")); } + final JsonObject oldRequests = obj(app, "approvalRequests"); + final JsonObject oldRequest = oldRequests == null ? null : obj(oldRequests, field); + if (oldRequest != null && "REVOKED".equals(str(oldRequest, "status"))) { + request.addProperty("previousRevokeRemarks", strOr(oldRequest, "revokeRemarks", str(oldRequest, "remarks"))); + request.addProperty("previousRevokedBy", str(oldRequest, "decidedBy")); + request.addProperty("previousRevokedOn", str(oldRequest, "decidedOn")); + } objOrCreate(app, "approvalRequests").add(field, request); appendHistory(app, expectedStage, user, "Sent for Approval", remarks); @@ -402,14 +482,14 @@ public class LoanOriginationStageService { } } - /** Checker step: a different user approves or rejects what was sent. */ + /** Checker step: a different user approves, rejects or revokes (returns for rectification) 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); + require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED", "REVOKED").contains(decision), "Unknown decision: " + decision); final String expectedStage = approvalStage(field); requireStage(app, expectedStage); @@ -417,33 +497,55 @@ public class LoanOriginationStageService { 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"); + "The person who sent this for approval cannot approve, reject or revoke it. Ask another user."); + + String remarks = strOr(input, "remarks", "").trim(); + if ("REVOKED".equals(decision)) { + require(!remarks.isBlank(), "Remarks are required when returning an approval request for correction."); + } else if ("REJECTED".equals(decision)) { + require(!remarks.isBlank(), "Give the reason for rejecting in the remarks box."); } - if (!"REJECTED".equals(decision)) { + if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) { + decision = strOr(request, "requestedDecision", "APPROVED"); requireCheckerVerified(request, input); } - // Prerequisites are only enforced for a positive decision: a rejection has to remain possible + // Prerequisites are only enforced for a positive decision: a rejection or revocation 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)) { + if (!"REJECTED".equals(decision) && !"REVOKED".equals(decision)) { 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)) { + if (remarks.isBlank()) { + remarks = "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved."; + } + + if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision) && !"REVOKED".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(resolution, "resolutionNo", ""); - final String resolutionDate = strOr(resolution, "resolutionDate", ""); + final boolean depositSanction = isDepositLoan(app); + final String resolutionNo = depositSanction ? applicationNo : strOr(resolution, "resolutionNo", ""); + final String resolutionDate = depositSanction ? today() : strOr(resolution, "resolutionDate", ""); app.addProperty("committeeResolutionNo", resolutionNo); app.addProperty("committeeResolutionDate", resolutionDate); - remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")"; + remarks = remarks + (depositSanction ? " (Sanction No. " : " (Resolution No. ") + resolutionNo + " dated " + resolutionDate + ")"; + } + + if ("REVOKED".equals(decision)) { + request.addProperty("status", "REVOKED"); + request.addProperty("decidedBy", user); + request.addProperty("decidedOn", today()); + request.addProperty("revokeRemarks", remarks); + request.addProperty("remarks", remarks); + if (app.has(field)) { + app.remove(field); + } + appendHistory(app, expectedStage, user, "Returned for Rectification", remarks); + return pipelineService.storeApplication(applicationNo, app); } final JsonObject approval = objOrCreate(app, field); @@ -460,11 +562,24 @@ public class LoanOriginationStageService { request.addProperty("decidedBy", user); request.addProperty("decidedOn", today()); - if ("REJECTED".equals(decision)) { + if ("disbursementApproval".equals(field)) { + // Approving the disbursement does not move the stage (the loan is disbursed next); declining it only sends it back. + if ("REJECTED".equals(decision)) { + app.remove(field); + appendHistory(app, expectedStage, user, "Disbursement Declined", remarks); + } else { + appendHistory(app, expectedStage, user, "Disbursement Approved", remarks); + } + } else if ("REJECTED".equals(decision)) { app.addProperty("overallStatus", "REJECTED"); appendHistory(app, str(app, "currentStage"), user, "Rejected", remarks); } else { advance(app, user, "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks); + if ("creditCommitteeApproval".equals(field) && isDepositLoan(app)) { + // Sanctioned: no risk approval and no legal approval on a loan against a deposit (the legal documents are taken at signing). + advance(app, user, "Risk Approval Not Required", "Loan against a deposit - sanctioned by the Sanction step."); + advance(app, user, "Legal Approval Not Required", "Loan against a deposit - the legal documents are taken at Customer Signing."); + } } return pipelineService.storeApplication(applicationNo, app); } @@ -475,6 +590,10 @@ public class LoanOriginationStageService { final JsonObject input = parseObject(body); requireStage(app, "CUSTOMER_SIGNING"); require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded."); + if (isDepositLoan(app)) { + // No legal approval stage on a loan against a deposit: its legal documents (stamp duty, DP note, lien letter ...) are taken here. + requireLegalChecklistDone(app); + } app.addProperty("customerSigningDate", today()); advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed", @@ -518,7 +637,9 @@ public class LoanOriginationStageService { requireStage(app, "DISBURSED"); require(!"DISBURSED".equals(str(app, "overallStatus")), "This application has already been disbursed."); - for (final String field : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval")) { + final List mustStand = isDepositLoan(app) ? List.of("creditCommitteeApproval", "postCheckApproval") + : List.of("creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval"); + for (final String field : mustStand) { final JsonObject approval = obj(app, field); final String decision = approval == null ? null : str(approval, "decision"); require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision), @@ -548,12 +669,38 @@ public class LoanOriginationStageService { app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId")); app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo")); } + holdDdsAccounts(applicationNo, app); appendHistory(app, "DISBURSED", actor(input), "Disbursed", strOr(input, "remarks", "Disbursed via " + mode + " — ACH confirmation " + strOr(app, "achConfirmationRef", "—") + ". EMI activation confirmed.")); return pipelineService.storeApplication(applicationNo, app); } + /** + * A loan against a Daily Deposit Scheme account puts the account on hold (its lien flag) as it is disbursed, naming the loan + * in the reason; {@link DdsLienReleaseService} lifts it when that loan ends. The DDS module is the bank's own, outside + * Fineract's deposit accounts, so there is no Fineract call for this. The outcome is written on the application's lien. + */ + private void holdDdsAccounts(final String applicationNo, final JsonObject app) { + if (!"DS".equals(str(app, "productCode"))) { + return; + } + final JsonObject selection = obj(app, "depositSelection"); + final JsonObject lien = obj(app, "lien"); + if (selection == null || lien == null || !bool(lien, "marked")) { + return; + } + final String loanNo = filled(app, "fineractLoanAccountNo") ? str(app, "fineractLoanAccountNo") : applicationNo; + final String reason = DdsLienReleaseService.reasonFor(loanNo, applicationNo); + int held = 0; + for (final JsonElement id : arr(selection, "accountIds")) { + held += jdbcTemplate.update("UPDATE m_dds_party SET lien = TRUE, lien_reason = ? WHERE id = ? AND status <> 'CLOSED'", reason, + id.getAsLong()); + } + lien.addProperty("coreStatus", held > 0 ? "PLACED" : "FAILED"); + lien.addProperty("coreNote", held > 0 ? "DDS account held: " + reason : "No open DDS account was found to hold."); + } + // ── Shared checklist rules (mirror the Application Detail screen's gating) ─ private void requireFieldInvestigationComplete(final JsonObject app) { @@ -577,8 +724,9 @@ public class LoanOriginationStageService { 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")) { + // On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional. + require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done."); + if (bool(app, "secured") && !depositLoan) { require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done."); } require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified."); @@ -588,6 +736,12 @@ public class LoanOriginationStageService { requireFieldInvestigationComplete(app); } require(depositLoan || bool(app, "managerMeetingDone"), "The manager meeting has not been recorded."); + if (depositLoan) { + // A loan against a deposit has no committee, risk or legal approval: this one Sanction step is all, and it makes the + // checks Risk would have made (KYC, the deposit covers the loan, the declarations). No resolution is needed. + requireRiskChecklistReady(app); + return; + } require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required."); require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required."); } @@ -609,15 +763,52 @@ public class LoanOriginationStageService { 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")) { + // A loan against a deposit takes no separate declaration: the deposit is the security. + require(depositLoan || bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken."); + if (bool(app, "secured") && !depositLoan) { require(bool(app, "propertySearchReportDone"), "The property search report is not on file."); require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed."); } if (!depositLoan) { requireFieldInvestigationComplete(app); } - require(bool(app, "backgroundCheckDone"), "The background check has not been done."); + // On a loan against a deposit the deposit is the security: KYC and the deposit checks are enough, the background check is optional. + require(isDepositLoan(app) || bool(app, "backgroundCheckDone"), "The background check has not been done."); + requireGuarantorsNamed(app); + } + + /** + * The guarantors an application has to name. A product that says how many (Product Creation) is held to that count: each + * guarantor an onboarded client, none twice, none the borrower, and a bank member unless the product says otherwise. An + * application raised before products said so keeps the officer's own Guarantor Required tick and its single guarantor. + */ + private void requireGuarantorsNamed(final JsonObject app) { + final JsonElement count = app.get("guarantorsRequiredCount"); + if (count != null && count.isJsonPrimitive() && count.getAsJsonPrimitive().isNumber()) { + final int need = count.getAsInt(); + if (need <= 0) { + return; + } + final boolean memberRequired = !app.has("guarantorMustBeMember") || bool(app, "guarantorMustBeMember"); + final Long borrower = LoanOriginationJson.lng(app, "clientId"); + final java.util.Set seen = new java.util.HashSet<>(); + for (final JsonElement element : arr(app, "guarantors")) { + if (!element.isJsonObject()) { + continue; + } + final JsonObject guarantor = element.getAsJsonObject(); + final Long clientId = LoanOriginationJson.lng(guarantor, "clientId"); + if (clientId == null) { + continue; + } + require(borrower == null || !borrower.equals(clientId), "The borrower cannot be their own guarantor."); + require(seen.add(clientId), "A guarantor has been named twice."); + require(!memberRequired || bool(guarantor, "hasShareAccount"), + strOr(guarantor, "name", "A guarantor") + " has no active share account: a guarantor must be a bank member."); + } + require(seen.size() >= need, "This product needs " + need + " guarantor(s); " + seen.size() + " named."); + return; + } if (bool(app, "guarantorRequired")) { // A guarantor at a co-operative bank has to be an existing member, not just a name: an // onboarded client id AND a live share account. diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml index 539be1edb..27ae3f9a7 100644 --- a/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml +++ b/fineract-provider/src/main/resources/db/changelog/tenant/changelog-tenant.xml @@ -634,6 +634,8 @@ + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_seed_priority_sector_category_code.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_seed_priority_sector_category_code.xml new file mode 100644 index 000000000..5124c4174 --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0608_seed_priority_sector_category_code.xml @@ -0,0 +1,54 @@ + + + + + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'PrioritySectorCategory', false + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'PrioritySectorCategory'); + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, v.order_position, true, false + FROM m_code c + CROSS JOIN (VALUES + ('Agriculture', 1), + ('Micro, Small and Medium Enterprises', 2), + ('Export Credit', 3), + ('Education', 4), + ('Housing', 5), + ('Social Infrastructure', 6), + ('Renewable Energy', 7), + ('Others', 8) + ) AS v(code_value, order_position) + WHERE c.code_name = 'PrioritySectorCategory' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value + ); + + + diff --git a/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_seed_weaker_section_type_code.xml b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_seed_weaker_section_type_code.xml new file mode 100644 index 000000000..041b5418c --- /dev/null +++ b/fineract-provider/src/main/resources/db/changelog/tenant/parts/0609_seed_weaker_section_type_code.xml @@ -0,0 +1,59 @@ + + + + + + + + INSERT INTO m_code (code_name, is_system_defined) + SELECT 'WeakerSectionType', false + WHERE NOT EXISTS (SELECT 1 FROM m_code WHERE code_name = 'WeakerSectionType'); + + INSERT INTO m_code_value (code_id, code_value, order_position, is_active, is_mandatory) + SELECT c.id, v.code_value, v.order_position, true, false + FROM m_code c + CROSS JOIN (VALUES + ('Small and Marginal Farmers', 1), + ('Artisans, village and cottage industries (individual credit limit up to ₹2 lakh)', 2), + ('Beneficiaries of Government Sponsored Schemes (NRLM, SRMS)', 3), + ('Scheduled Castes and Scheduled Tribes', 4), + ('Beneficiaries of Differential Rate of Interest (DRI) scheme', 5), + ('Self Help Groups / Joint Liability Groups', 6), + ('Individuals and members of SHGs/JLGs availing microfinance loans', 7), + ('Individual women beneficiaries (up to ₹2 lakh)', 8), + ('Distressed farmers indebted to non-institutional lenders', 9), + ('Distressed persons other than farmers (up to ₹1 lakh)', 10), + ('Persons with disabilities', 11), + ('Transgenders', 12), + ('Minority communities notified by Government of India', 13) + ) AS v(code_value, order_position) + WHERE c.code_name = 'WeakerSectionType' + AND NOT EXISTS ( + SELECT 1 FROM m_code_value cv + WHERE cv.code_id = c.id AND cv.code_value = v.code_value + ); + + +