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 <noreply@anthropic.com>
This commit is contained in:
2026-10-30 15:28:28 +05:30
co-authored by Claude Sonnet 5.5
parent ceb8e97901
commit dfb22a1898
6 changed files with 420 additions and 24 deletions
@@ -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).
*
* <p>
* 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<LoanStatusChangedBusinessEvent> {
@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());
}
}
}
}
@@ -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<String> 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) {
@@ -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<String> 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<String> 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<String> 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.");
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<String> 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 ("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<String> 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<Long> 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.
@@ -634,6 +634,8 @@
<include file="parts/0605_add_gold_revaluation_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0606_add_gold_servicing_and_auction_rules.xml" relativeToChangelogFile="true"/>
<include file="parts/0607_add_warehouse_receipt_product_fields.xml" relativeToChangelogFile="true"/>
<include file="parts/0608_seed_priority_sector_category_code.xml" relativeToChangelogFile="true"/>
<include file="parts/0609_seed_weaker_section_type_code.xml" relativeToChangelogFile="true"/>
<!-- DDS: the scheme's own tables, settings and permissions. Kept together and last, because
each one builds on the core the block above sets up and none of it builds on DDS. -->
@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- RBI priority sector lending categories, kept as a Code so Loan Origination and the loan account wizard read
the list from the system instead of a fixed list. Names are shown as they are. -->
<changeSet author="CBS" id="0608_seed_priority_sector_category_code">
<sql>
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
);
</sql>
</changeSet>
</databaseChangeLog>
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Licensed to the Apache Software Foundation (ASF) under one
or more contributor license agreements. See the NOTICE file
distributed with this work for additional information
regarding copyright ownership. The ASF licenses this file
to you under the Apache License, Version 2.0 (the
"License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<databaseChangeLog xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.1.xsd">
<!-- Weaker sections of the RBI Priority Sector Lending Master Directions (para 17.1): an overlapping tag on a priority
sector loan, chosen at sanction in Loan Origination. Kept as a Code so the list can be maintained in Admin. -->
<changeSet author="CBS" id="0609_seed_weaker_section_type_code">
<sql>
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
);
</sql>
</changeSet>
</databaseChangeLog>