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
+ );
+
+
+