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