feat(loan-origination): add full backend persistence for the sanctioning pipeline
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 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 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 - MySQL / test (test-core-5) (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-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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 Documentation build / build (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (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 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 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 - MySQL / test (test-core-5) (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-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 Cargo & Unit- & Integration tests - PostgreSQL / test (test-core-5) (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 Documentation build / build (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 Liquibase Only mode - PostgreSQL / test (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with ActiveMQ (push) Canceled after 0s
Fineract Messaging Smoke Tests / Smoke Test with Kafka (push) Canceled after 0s
Adds a lightweight loan-origination-pipeline module (application, stage history, approvals, FI checks and documents) with server-side validation per stage transition, so every field, upload and decision on the Loan Origination screen survives a page refresh instead of living only in the browser. Also fixes two content-store bugs found while wiring document uploads through the generic Fineract document store: the CSV mime/extension whitelist rejected bank-statement uploads outright, and the post-upload mime consistency check rejected any CSV whose byte-content sniffs as text/plain (which is all of them, since CSV has no distinguishing magic bytes). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+277
@@ -0,0 +1,277 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanoriginationpipeline.api;
|
||||
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.ws.rs.Consumes;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.POST;
|
||||
import jakarta.ws.rs.PUT;
|
||||
import jakarta.ws.rs.Path;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.Produces;
|
||||
import jakarta.ws.rs.QueryParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.security.service.PlatformSecurityContext;
|
||||
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationPipelineService;
|
||||
import org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationStageService;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* REST endpoints for the Loan Origination Pipeline — the sanctioning workflow an application walks
|
||||
* through before a real loan account is ever opened (Application → KYC/OVD → CIC Credit Bureau Check
|
||||
* → Field Investigation → Personal Discussion → Credit Committee → Risk Approval → Legal Approval →
|
||||
* Customer Signing → Cheque/ACH → Post-Disbursement Check → Disbursed).
|
||||
*
|
||||
* <pre>
|
||||
* GET /v1/loan-origination/applications – list (optional branchCode/stage/status filters)
|
||||
* GET /v1/loan-origination/applications/{applicationNo} – one application, in full
|
||||
* POST /v1/loan-origination/applications – create from the New Application wizard
|
||||
* PUT /v1/loan-origination/applications/{applicationNo} – save the whole application back
|
||||
* POST /v1/loan-origination/applications/seed – one-time seed; no-ops if already seeded
|
||||
* </pre>
|
||||
*
|
||||
* <p>
|
||||
* Requests/responses are the application object verbatim, so the request body is passed straight
|
||||
* through as JSON rather than being bound to a DTO. A DTO would have to mirror all ~111 workflow
|
||||
* fields and would silently drop any field it had not been updated to know about — the exact failure
|
||||
* this module has to avoid, since the workflow gains fields regularly.
|
||||
*
|
||||
* <p>
|
||||
* Authorisation is a normal authenticated staff session. These endpoints deliberately do not define
|
||||
* their own permission codes: the pipeline is the sanctioning process for lending, so it is gated by
|
||||
* the same authenticated-staff check the rest of the loan screens use, and per-stage authority
|
||||
* (who may sit on the Credit Committee, who signs off Risk/Legal) is a workflow concern enforced on
|
||||
* the application record itself, not a Fineract entity permission.
|
||||
*/
|
||||
@Path("/v1/loan-origination/applications")
|
||||
@Component
|
||||
@Tag(name = "Loan Origination Pipeline",
|
||||
description = "Pre-disbursement sanctioning workflow: KYC/OVD, CIC credit bureau check, Field Investigation, "
|
||||
+ "Personal Discussion, Credit Committee, Risk & Legal approval, signing and disbursement.")
|
||||
@RequiredArgsConstructor
|
||||
public class LoanOriginationPipelineApiResource {
|
||||
|
||||
private final PlatformSecurityContext context;
|
||||
private final LoanOriginationPipelineService service;
|
||||
private final LoanOriginationStageService stageService;
|
||||
|
||||
@GET
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "List loan origination applications",
|
||||
description = "Newest first. Filters are optional — the Pipeline screen also filters client-side.")
|
||||
public String retrieveAll(@QueryParam("branchCode") final String branchCode, @QueryParam("stage") final String stage,
|
||||
@QueryParam("status") final String status) {
|
||||
this.context.authenticatedUser();
|
||||
return this.service.findAllAsJson(branchCode, stage, status);
|
||||
}
|
||||
|
||||
@GET
|
||||
@Path("{applicationNo}")
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Retrieve one loan origination application in full")
|
||||
public String retrieveOne(@PathParam("applicationNo") final String applicationNo) {
|
||||
this.context.authenticatedUser();
|
||||
return this.service.findOneAsJson(applicationNo);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Create a loan origination application",
|
||||
description = "Body is the application object as built by the New Application wizard.")
|
||||
public String create(final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.service.create(apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@PUT
|
||||
@Path("{applicationNo}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Save a loan origination application",
|
||||
description = "Full-object save: the body replaces the stored application, its stage history, "
|
||||
+ "approvals, field-investigation checks and document checklists.")
|
||||
public String save(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.service.save(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("seed")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Seed the pipeline once",
|
||||
description = "Inserts the supplied applications only when the pipeline is empty, so reloading the "
|
||||
+ "frontend cannot duplicate the book. Returns the number actually inserted.")
|
||||
public String seed(final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return "{\"seeded\":" + this.service.seed(apiRequestBodyAsJson) + "}";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Per-stage transitions.
|
||||
//
|
||||
// Each of these takes only its own step's inputs (who acted, remarks, a resolution number) and
|
||||
// lets the server validate the stage's prerequisites against the stored record before applying
|
||||
// the change. The full-object PUT above still exists for inline field edits; these exist because
|
||||
// a sanctioning decision should not be something a caller can simply assert. Every one returns
|
||||
// the updated application, so the client replaces its copy rather than guessing the result.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/kyc-complete")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Complete KYC & Document Collection",
|
||||
description = "Requires Aadhaar/PAN verified, income proof, bank statement and address proof submitted, "
|
||||
+ "income assessed, and every product-specific document submitted.")
|
||||
public String completeKyc(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.completeKyc(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/cic-complete")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Complete the CIC / credit bureau check",
|
||||
description = "Requires the borrower's signed consent, the collected bureau charge and an uploaded report.")
|
||||
public String completeCicCheck(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.completeCicCheck(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/field-investigation/{checkKey}/verify")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Mark one field investigation check verified",
|
||||
description = "Requires an assigned officer, GPS coordinates and at least one site photograph "
|
||||
+ "to already be recorded against the visit.")
|
||||
public String verifyFieldCheck(@PathParam("applicationNo") final String applicationNo,
|
||||
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.verifyFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/field-investigation/{checkKey}/reject")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Mark one field investigation check failed", description = "A reason is mandatory.")
|
||||
public String rejectFieldCheck(@PathParam("applicationNo") final String applicationNo,
|
||||
@PathParam("checkKey") final String checkKey, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.rejectFieldCheck(applicationNo, checkKey, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/field-investigation/complete")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Complete Field Investigation",
|
||||
description = "Requires every applicable check (address, office, plus property when secured and shop "
|
||||
+ "when the applicant runs one) to be verified.")
|
||||
public String completeFieldInvestigation(@PathParam("applicationNo") final String applicationNo,
|
||||
final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.completeFieldInvestigation(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/personal-discussion/clear")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Clear the personal discussion and proceed")
|
||||
public String clearPersonalDiscussion(@PathParam("applicationNo") final String applicationNo,
|
||||
final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.clearPersonalDiscussion(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/personal-discussion/flag")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Flag a discrepancy and hold the application",
|
||||
description = "A discrepancy type and notes are both mandatory.")
|
||||
public String flagDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.flagDiscrepancy(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/personal-discussion/resolve")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Resolve a held discrepancy and proceed")
|
||||
public String resolveDiscrepancy(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.resolveDiscrepancy(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/approvals/{field}")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Record an approval decision",
|
||||
description = "`field` is one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval. "
|
||||
+ "An APPROVED decision is checked against that stage's own checklist — the Credit Committee also "
|
||||
+ "requires a resolution number and date. A REJECTED decision is always permitted.")
|
||||
public String decideApproval(@PathParam("applicationNo") final String applicationNo, @PathParam("field") final String field,
|
||||
final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.decideApproval(applicationNo, field, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/signing")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Record the customer's signing of the loan agreement")
|
||||
public String markSigned(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.markSigned(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/cheque-ach")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Confirm security cheques received and ACH mandate signed")
|
||||
public String confirmChequeAch(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.confirmChequeAch(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
|
||||
@POST
|
||||
@Path("{applicationNo}/disburse")
|
||||
@Consumes(MediaType.APPLICATION_JSON)
|
||||
@Produces(MediaType.APPLICATION_JSON)
|
||||
@Operation(summary = "Disburse the loan",
|
||||
description = "Requires all four approval levels to stand as approved and the customer to have signed. "
|
||||
+ "Optionally records the real Fineract loan account created at disbursement.")
|
||||
public String disburse(@PathParam("applicationNo") final String applicationNo, final String apiRequestBodyAsJson) {
|
||||
this.context.authenticatedUser();
|
||||
return this.stageService.disburse(applicationNo, apiRequestBodyAsJson);
|
||||
}
|
||||
}
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanoriginationpipeline.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.math.BigDecimal;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
|
||||
/**
|
||||
* Null-safe accessors for the loan origination application object.
|
||||
*
|
||||
* <p>
|
||||
* The application is handled as JSON rather than a typed entity (see
|
||||
* {@link LoanOriginationPipelineService} for why), so every read has to tolerate a key being absent,
|
||||
* explicitly null, or the wrong JSON type — an older application stored before a field existed is a
|
||||
* normal, expected case, not an error. These helpers turn all three into a sensible default so no
|
||||
* caller has to repeat the checks.
|
||||
*/
|
||||
public final class LoanOriginationJson {
|
||||
|
||||
private LoanOriginationJson() {}
|
||||
|
||||
public static JsonObject parseObject(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
// An action with no inputs (e.g. "mark signed") legitimately posts an empty body.
|
||||
return new JsonObject();
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(body);
|
||||
if (!parsed.isJsonObject()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.invalid",
|
||||
"Request body must be a JSON object.");
|
||||
}
|
||||
return parsed.getAsJsonObject();
|
||||
}
|
||||
|
||||
public static boolean present(final JsonObject obj, final String key) {
|
||||
return obj != null && obj.has(key) && !obj.get(key).isJsonNull();
|
||||
}
|
||||
|
||||
public static String str(final JsonObject obj, final String key) {
|
||||
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsString() : null;
|
||||
}
|
||||
|
||||
/** Trimmed value, or the fallback when absent/blank. */
|
||||
public static String strOr(final JsonObject obj, final String key, final String fallback) {
|
||||
final String value = str(obj, key);
|
||||
return value == null || value.isBlank() ? fallback : value.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a field holds real content. Treats the {@code '—'} em-dash as empty: the workflow uses
|
||||
* it as a "nothing recorded yet" placeholder throughout, so a check for "has this been filled in"
|
||||
* has to reject it or every blank field would read as populated.
|
||||
*/
|
||||
public static boolean filled(final JsonObject obj, final String key) {
|
||||
final String value = str(obj, key);
|
||||
return value != null && !value.trim().isEmpty() && !"—".equals(value.trim());
|
||||
}
|
||||
|
||||
public static Long lng(final JsonObject obj, final String key) {
|
||||
try {
|
||||
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsLong() : null;
|
||||
} catch (final NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static BigDecimal dec(final JsonObject obj, final String key) {
|
||||
try {
|
||||
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsBigDecimal() : null;
|
||||
} catch (final NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean bool(final JsonObject obj, final String key) {
|
||||
return present(obj, key) && obj.get(key).isJsonPrimitive() && obj.get(key).getAsBoolean();
|
||||
}
|
||||
|
||||
public static int intOr(final JsonObject obj, final String key, final int fallback) {
|
||||
try {
|
||||
return present(obj, key) && obj.get(key).isJsonPrimitive() ? obj.get(key).getAsInt() : fallback;
|
||||
} catch (final NumberFormatException e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
public static JsonObject obj(final JsonObject parent, final String key) {
|
||||
return present(parent, key) && parent.get(key).isJsonObject() ? parent.getAsJsonObject(key) : null;
|
||||
}
|
||||
|
||||
/** The nested object, creating and attaching an empty one if it isn't there yet. */
|
||||
public static JsonObject objOrCreate(final JsonObject parent, final String key) {
|
||||
final JsonObject existing = obj(parent, key);
|
||||
if (existing != null) {
|
||||
return existing;
|
||||
}
|
||||
final JsonObject created = new JsonObject();
|
||||
parent.add(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public static JsonArray arr(final JsonObject parent, final String key) {
|
||||
return present(parent, key) && parent.get(key).isJsonArray() ? parent.getAsJsonArray(key) : new JsonArray();
|
||||
}
|
||||
|
||||
public static JsonArray arrOrCreate(final JsonObject parent, final String key) {
|
||||
if (present(parent, key) && parent.get(key).isJsonArray()) {
|
||||
return parent.getAsJsonArray(key);
|
||||
}
|
||||
final JsonArray created = new JsonArray();
|
||||
parent.add(key, created);
|
||||
return created;
|
||||
}
|
||||
|
||||
public static String rawJson(final JsonObject obj, final String key) {
|
||||
return present(obj, key) ? obj.get(key).toString() : null;
|
||||
}
|
||||
}
|
||||
+403
@@ -0,0 +1,403 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanoriginationpipeline.service;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Persistence for the Loan Origination Pipeline — the pre-disbursement sanctioning workflow
|
||||
* (Application → KYC → CIC Check → Field Investigation → Personal Discussion → Credit Committee →
|
||||
* Risk → Legal → Signing → Cheque/ACH → Post-Check → Disbursed).
|
||||
*
|
||||
* <p>
|
||||
* <b>Storage shape.</b> {@code payload} holds the complete application object as JSON and is the
|
||||
* single source of truth for reads. The dozen real columns on
|
||||
* {@code m_loan_origination_application}, and the four child tables, are <i>derived indexes</i> —
|
||||
* they exist so the Pipeline list, the Channel/BC/Share reports, Target-vs-Actual and audit queries
|
||||
* ("every application this officer approved") can be plain SQL instead of scanning JSON. They are
|
||||
* never read back into the application object.
|
||||
*
|
||||
* <p>
|
||||
* That deliberately stores those fields twice, which is the right trade here: every write goes
|
||||
* through {@link #writeApplication} in one transaction and rewrites the payload, the columns and all
|
||||
* child rows together from the same source, so the copies cannot drift. The alternative — splitting
|
||||
* fields so nothing repeats — would force the read path to reconstruct a dozen values back out of
|
||||
* SQL types (a {@code DECIMAL(19,6)} returning {@code 100000.000000} where the UI expects
|
||||
* {@code 100000}, a null {@code BIGINT} vs an absent key), which is exactly the class of silent
|
||||
* field-level corruption this module cannot afford.
|
||||
*
|
||||
* <p>
|
||||
* The one field kept out of {@code payload} is {@code cicParsedAnalysis} — a parsed credit-bureau
|
||||
* report with a per-account seven-year day-past-due grid. It is large, opaque and only ever rendered
|
||||
* whole, so it gets its own column rather than being duplicated alongside everything else.
|
||||
*
|
||||
* <p>
|
||||
* Because the payload is "the whole object minus that one field" rather than an allowlist of known
|
||||
* keys, a field added to the workflow later persists with no schema or code change here. Given how
|
||||
* fast this workflow gains fields, an allowlist would drop them silently.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanOriginationPipelineService {
|
||||
|
||||
/** Pulled out of the payload into its own column — see class javadoc. */
|
||||
private static final String CIC_ANALYSIS_KEY = "cicParsedAnalysis";
|
||||
|
||||
/**
|
||||
* The row's primary key. Kept out of the payload so it can only ever come from the column — a
|
||||
* stale id round-tripped inside the JSON would silently point uploaded documents at the wrong
|
||||
* application.
|
||||
*/
|
||||
private static final String ID_KEY = "id";
|
||||
|
||||
private static final String SELECT_COLUMNS = "id, payload, cic_parsed_analysis";
|
||||
|
||||
private final JdbcTemplate jdbcTemplate;
|
||||
|
||||
// ── Read ─────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Every application, newest first. Optional filters are pushed into SQL; the Pipeline screen also
|
||||
* filters in-memory, so passing nothing here is perfectly valid.
|
||||
*/
|
||||
public String findAllAsJson(final String branchCode, final String stage, final String status) {
|
||||
final StringBuilder sql = new StringBuilder("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE 1=1");
|
||||
final List<Object> params = new ArrayList<>();
|
||||
if (isSet(branchCode)) {
|
||||
sql.append(" AND branch_code = ?");
|
||||
params.add(branchCode);
|
||||
}
|
||||
if (isSet(stage)) {
|
||||
sql.append(" AND current_stage = ?");
|
||||
params.add(stage);
|
||||
}
|
||||
if (isSet(status)) {
|
||||
sql.append(" AND overall_status = ?");
|
||||
params.add(status);
|
||||
}
|
||||
sql.append(" ORDER BY id DESC");
|
||||
|
||||
final List<Map<String, Object>> rows = jdbcTemplate.queryForList(sql.toString(), params.toArray());
|
||||
final JsonArray out = new JsonArray();
|
||||
for (final Map<String, Object> row : rows) {
|
||||
out.add(reassemble(row));
|
||||
}
|
||||
return out.toString();
|
||||
}
|
||||
|
||||
public String findOneAsJson(final String applicationNo) {
|
||||
return loadApplication(applicationNo).toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads one application as a mutable object. This is the entry point the per-stage endpoints use:
|
||||
* they validate and apply their transition against the state that is actually stored, never
|
||||
* against what a caller claims it is.
|
||||
*/
|
||||
public JsonObject loadApplication(final String applicationNo) {
|
||||
final List<Map<String, Object>> rows = jdbcTemplate
|
||||
.queryForList("SELECT " + SELECT_COLUMNS + " FROM m_loan_origination_application WHERE application_no = ?",
|
||||
applicationNo);
|
||||
if (rows.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
|
||||
"No loan origination application found with number " + applicationNo + ".", applicationNo);
|
||||
}
|
||||
return reassemble(rows.get(0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the application object: the stored payload, with the bureau report re-attached and the
|
||||
* row's own id surfaced. The id is what uploaded files hang off — documents are stored against
|
||||
* {@code m_document.parent_entity_id}, so the client needs it to upload and fetch attachments.
|
||||
*/
|
||||
private JsonObject reassemble(final Map<String, Object> row) {
|
||||
final JsonObject app = parseObject(asString(row.get("payload")));
|
||||
final Object id = row.get("id");
|
||||
if (id != null) {
|
||||
app.addProperty("id", Long.valueOf(id.toString()));
|
||||
}
|
||||
final String cic = asString(row.get("cic_parsed_analysis"));
|
||||
if (cic != null && !cic.isBlank()) {
|
||||
app.add(CIC_ANALYSIS_KEY, JsonParser.parseString(cic));
|
||||
}
|
||||
return app;
|
||||
}
|
||||
|
||||
// ── Write ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Creates an application. The caller (the New Application wizard) has already derived the
|
||||
* application number and the blank per-stage structures, so this stores the object as given
|
||||
* rather than re-deriving a second, competing set of defaults server-side.
|
||||
*/
|
||||
@Transactional
|
||||
public String create(final String requestBodyAsJson) {
|
||||
final JsonObject app = parseObject(requestBodyAsJson);
|
||||
final String applicationNo = stringOrNull(app, "applicationNo");
|
||||
if (applicationNo == null || applicationNo.isBlank()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.no.required",
|
||||
"applicationNo is required.");
|
||||
}
|
||||
final Long existing = jdbcTemplate.queryForObject(
|
||||
"SELECT COUNT(*) FROM m_loan_origination_application WHERE application_no = ?", Long.class, applicationNo);
|
||||
if (existing != null && existing > 0) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.duplicate",
|
||||
"An application numbered " + applicationNo + " already exists.", applicationNo);
|
||||
}
|
||||
// Read back rather than echoing the request: the caller needs the assigned id to be able to
|
||||
// attach documents to this application.
|
||||
insertApplication(app);
|
||||
return findOneAsJson(applicationNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-object save. The workflow's stage transitions are all applied client-side against the
|
||||
* whole application object, so the whole object is what gets written back — there is no
|
||||
* per-action field allowlist that could quietly omit a field.
|
||||
*/
|
||||
@Transactional
|
||||
public String save(final String applicationNo, final String requestBodyAsJson) {
|
||||
return storeApplication(applicationNo, parseObject(requestBodyAsJson));
|
||||
}
|
||||
|
||||
/** Writes a whole application object back. Shared by the full-object save and every per-stage
|
||||
* endpoint, so both paths persist through exactly the same code. */
|
||||
@Transactional
|
||||
public String storeApplication(final String applicationNo, final JsonObject app) {
|
||||
final Long id = findIdOrThrow(applicationNo);
|
||||
|
||||
final JsonObject payload = app.deepCopy();
|
||||
payload.remove(CIC_ANALYSIS_KEY);
|
||||
payload.remove(ID_KEY);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
UPDATE m_loan_origination_application SET
|
||||
applicant_name = ?, client_id = ?, branch_code = ?, channel = ?, bc_agent_name = ?,
|
||||
product_code = ?, requested_amount = ?, current_stage = ?, overall_status = ?,
|
||||
is_share_member = ?, fineract_loan_id = ?, payload = ?, cic_parsed_analysis = ?,
|
||||
last_modified_date = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""", stringOrNull(app, "applicantName"), longOrNull(app, "clientId"), stringOrNull(app, "branchCode"),
|
||||
stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"), stringOrNull(app, "productCode"),
|
||||
decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"), stringOrNull(app, "overallStatus"),
|
||||
boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"), payload.toString(),
|
||||
childJsonOrNull(app, CIC_ANALYSIS_KEY), id);
|
||||
|
||||
writeChildren(id, app);
|
||||
return findOneAsJson(applicationNo);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time seed. Idempotent by design: it is called on first page load and must not duplicate the
|
||||
* book if the frontend is reloaded, so a non-empty table short-circuits to 0.
|
||||
*/
|
||||
@Transactional
|
||||
public int seed(final String requestBodyAsJson) {
|
||||
final Long existing = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM m_loan_origination_application", Long.class);
|
||||
if (existing != null && existing > 0) {
|
||||
return 0;
|
||||
}
|
||||
final JsonElement parsed = JsonParser.parseString(requestBodyAsJson == null ? "[]" : requestBodyAsJson);
|
||||
if (!parsed.isJsonArray()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.seed.invalid",
|
||||
"Seed body must be a JSON array of applications.");
|
||||
}
|
||||
int seeded = 0;
|
||||
for (final JsonElement element : parsed.getAsJsonArray()) {
|
||||
if (element.isJsonObject()) {
|
||||
insertApplication(element.getAsJsonObject());
|
||||
seeded++;
|
||||
}
|
||||
}
|
||||
return seeded;
|
||||
}
|
||||
|
||||
private void insertApplication(final JsonObject app) {
|
||||
final JsonObject payload = app.deepCopy();
|
||||
payload.remove(CIC_ANALYSIS_KEY);
|
||||
payload.remove(ID_KEY);
|
||||
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_loan_origination_application
|
||||
(application_no, applicant_name, client_id, branch_code, channel, bc_agent_name,
|
||||
product_code, requested_amount, current_stage, overall_status, is_share_member,
|
||||
fineract_loan_id, payload, cic_parsed_analysis)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", stringOrNull(app, "applicationNo"), stringOrNull(app, "applicantName"), longOrNull(app, "clientId"),
|
||||
stringOrNull(app, "branchCode"), stringOrNull(app, "channel"), stringOrNull(app, "bcAgentName"),
|
||||
stringOrNull(app, "productCode"), decimalOrNull(app, "requestedAmount"), stringOrNull(app, "currentStage"),
|
||||
stringOrNull(app, "overallStatus"), boolOrFalse(app, "isShareMember"), longOrNull(app, "fineractLoanId"),
|
||||
payload.toString(), childJsonOrNull(app, CIC_ANALYSIS_KEY));
|
||||
|
||||
final Long id = jdbcTemplate.queryForObject("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
|
||||
Long.class, stringOrNull(app, "applicationNo"));
|
||||
writeChildren(id, app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites the derived child rows for one application. Delete-then-insert rather than a per-row
|
||||
* diff: these collections are small (a stage history is a dozen rows), and a full rewrite cannot
|
||||
* leave a stale row behind the way a partial diff can.
|
||||
*/
|
||||
private void writeChildren(final Long id, final JsonObject app) {
|
||||
jdbcTemplate.update("DELETE FROM m_loan_origination_stage_history WHERE application_id = ?", id);
|
||||
jdbcTemplate.update("DELETE FROM m_loan_origination_approval WHERE application_id = ?", id);
|
||||
jdbcTemplate.update("DELETE FROM m_loan_origination_fi_check WHERE application_id = ?", id);
|
||||
jdbcTemplate.update("DELETE FROM m_loan_origination_document WHERE application_id = ?", id);
|
||||
|
||||
final JsonArray history = arrayOrEmpty(app, "stageHistory");
|
||||
for (int i = 0; i < history.size(); i++) {
|
||||
if (!history.get(i).isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject entry = history.get(i).getAsJsonObject();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_loan_origination_stage_history
|
||||
(application_id, seq_no, stage, action_date, action_by, outcome, remarks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", id, i, stringOrNull(entry, "stage"), stringOrNull(entry, "date"), stringOrNull(entry, "actionBy"),
|
||||
stringOrNull(entry, "outcome"), stringOrNull(entry, "remarks"));
|
||||
}
|
||||
|
||||
for (final String field : new String[] { "creditCommitteeApproval", "riskApproval", "legalApproval", "postCheckApproval" }) {
|
||||
final JsonObject approval = objectOrNull(app, field);
|
||||
if (approval == null) {
|
||||
continue;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_loan_origination_approval
|
||||
(application_id, approval_field, decision, approver_name, approver_role, decision_date, remarks)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
""", id, field, stringOrNull(approval, "decision"), stringOrNull(approval, "approverName"),
|
||||
stringOrNull(approval, "approverRole"), stringOrNull(approval, "date"), stringOrNull(approval, "remarks"));
|
||||
}
|
||||
|
||||
for (final String key : new String[] { "addressVerification", "officeVerification", "propertyVerification",
|
||||
"shopVerification" }) {
|
||||
final JsonObject check = objectOrNull(app, key);
|
||||
if (check == null) {
|
||||
continue;
|
||||
}
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_loan_origination_fi_check
|
||||
(application_id, check_key, status, verified_by, verified_date, remarks, officer_assigned,
|
||||
assigned_date, assigned_at, latitude, longitude, photo_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", id, key, stringOrNull(check, "status"), stringOrNull(check, "verifiedBy"),
|
||||
stringOrNull(check, "verifiedDate"), stringOrNull(check, "remarks"), stringOrNull(check, "officerAssigned"),
|
||||
stringOrNull(check, "assignedDate"), longOrNull(check, "assignedAt"), stringOrNull(check, "latitude"),
|
||||
stringOrNull(check, "longitude"), intOrZero(check, "photoCount"));
|
||||
}
|
||||
|
||||
insertDocuments(id, arrayOrEmpty(app, "productDocuments"), "PRODUCT");
|
||||
insertDocuments(id, arrayOrEmpty(app, "legalDocuments"), "LEGAL");
|
||||
}
|
||||
|
||||
private void insertDocuments(final Long id, final JsonArray docs, final String kind) {
|
||||
for (int i = 0; i < docs.size(); i++) {
|
||||
if (!docs.get(i).isJsonObject()) {
|
||||
continue;
|
||||
}
|
||||
final JsonObject doc = docs.get(i).getAsJsonObject();
|
||||
jdbcTemplate.update("""
|
||||
INSERT INTO m_loan_origination_document
|
||||
(application_id, doc_kind, doc_key, label, hint, is_custom, submitted, seq_no)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""", id, kind, stringOrNull(doc, "key"), stringOrNull(doc, "label"), stringOrNull(doc, "hint"),
|
||||
boolOrFalse(doc, "custom"), boolOrFalse(doc, "submitted"), i);
|
||||
}
|
||||
}
|
||||
|
||||
private Long findIdOrThrow(final String applicationNo) {
|
||||
final List<Long> ids = jdbcTemplate.queryForList("SELECT id FROM m_loan_origination_application WHERE application_no = ?",
|
||||
Long.class, applicationNo);
|
||||
if (ids.isEmpty()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.application.not.found",
|
||||
"No loan origination application found with number " + applicationNo + ".", applicationNo);
|
||||
}
|
||||
return ids.get(0);
|
||||
}
|
||||
|
||||
// ── JSON helpers ─────────────────────────────────────────────────────────
|
||||
// Thin aliases over LoanOriginationJson so this class and the per-stage service share one
|
||||
// implementation of the null/absent/wrong-type handling rather than each keeping its own.
|
||||
|
||||
private JsonObject parseObject(final String body) {
|
||||
if (body == null || body.isBlank()) {
|
||||
throw new GeneralPlatformDomainRuleException("error.msg.loan.origination.body.required", "A request body is required.");
|
||||
}
|
||||
return LoanOriginationJson.parseObject(body);
|
||||
}
|
||||
|
||||
private boolean present(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.present(obj, key);
|
||||
}
|
||||
|
||||
private String stringOrNull(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.str(obj, key);
|
||||
}
|
||||
|
||||
private Long longOrNull(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.lng(obj, key);
|
||||
}
|
||||
|
||||
private BigDecimal decimalOrNull(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.dec(obj, key);
|
||||
}
|
||||
|
||||
private boolean boolOrFalse(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.bool(obj, key);
|
||||
}
|
||||
|
||||
private int intOrZero(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.intOr(obj, key, 0);
|
||||
}
|
||||
|
||||
private JsonObject objectOrNull(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.obj(obj, key);
|
||||
}
|
||||
|
||||
private JsonArray arrayOrEmpty(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.arr(obj, key);
|
||||
}
|
||||
|
||||
private String childJsonOrNull(final JsonObject obj, final String key) {
|
||||
return LoanOriginationJson.rawJson(obj, key);
|
||||
}
|
||||
|
||||
private String asString(final Object value) {
|
||||
return value == null ? null : value.toString();
|
||||
}
|
||||
|
||||
private boolean isSet(final String value) {
|
||||
return value != null && !value.isBlank();
|
||||
}
|
||||
}
|
||||
+517
@@ -0,0 +1,517 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
package org.apache.fineract.portfolio.loanoriginationpipeline.service;
|
||||
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arr;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.arrOrCreate;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.bool;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.filled;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.intOr;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.obj;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.objOrCreate;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.parseObject;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.str;
|
||||
import static org.apache.fineract.portfolio.loanoriginationpipeline.service.LoanOriginationJson.strOr;
|
||||
|
||||
import com.google.gson.JsonArray;
|
||||
import com.google.gson.JsonElement;
|
||||
import com.google.gson.JsonObject;
|
||||
import java.time.LocalDate;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.apache.fineract.infrastructure.core.exception.GeneralPlatformDomainRuleException;
|
||||
import org.apache.fineract.infrastructure.core.service.DateUtils;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* Server-side stage transitions for the Loan Origination Pipeline.
|
||||
*
|
||||
* <p>
|
||||
* Each method here owns one sanctioning step: it loads the application <b>as stored</b>, checks that
|
||||
* step's preconditions against that stored state, applies the change, appends the audit-trail entry
|
||||
* and saves. The caller supplies only that step's own inputs (who acted, remarks, a resolution
|
||||
* number) — never the resulting state — so a client cannot approve a stage whose prerequisites are
|
||||
* not actually met, mark a field visit verified without the GPS fix and photographs really being on
|
||||
* file, or minute a Credit Committee decision without a resolution number.
|
||||
*
|
||||
* <p>
|
||||
* That distinction matters for a sanctioning workflow: the screen also disables those buttons, but UI
|
||||
* gating is a convenience, not a control — it is bypassed by any direct call to the API. These checks
|
||||
* are the control. They are deliberately the same rules the screen enforces, so an officer never sees
|
||||
* an enabled button that the server then rejects.
|
||||
*
|
||||
* <p>
|
||||
* Dates are written in the {@code dd-MM-yyyy} form the rest of the workflow record already uses, and
|
||||
* "nothing recorded" stays the {@code '—'} placeholder the existing data uses, so a server-applied
|
||||
* transition is indistinguishable in shape from the client-applied ones already stored.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class LoanOriginationStageService {
|
||||
|
||||
private static final DateTimeFormatter WORKFLOW_DATE = DateTimeFormatter.ofPattern("dd-MM-yyyy");
|
||||
|
||||
/** Must stay in step with STAGE_ORDER on the Application Detail screen. */
|
||||
private static final List<String> STAGE_ORDER = List.of("APPLICATION", "KYC_DOCUMENTS", "CIC_CHECK", "FIELD_INVESTIGATION",
|
||||
"PERSONAL_DISCUSSION", "CREDIT_COMMITTEE", "RISK_APPROVAL", "LEGAL_APPROVAL", "CUSTOMER_SIGNING", "CHEQUE_ACH",
|
||||
"POST_CHECK", "DISBURSED");
|
||||
|
||||
private static final List<String> FI_CHECK_KEYS = List.of("addressVerification", "officeVerification", "propertyVerification",
|
||||
"shopVerification");
|
||||
|
||||
private final LoanOriginationPipelineService pipelineService;
|
||||
|
||||
// ── KYC & Documents ──────────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public String completeKyc(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "KYC_DOCUMENTS");
|
||||
|
||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
require(bool(app, "bankStatementSubmitted"), "Bank statement has not been submitted.");
|
||||
require(bool(app, "electricityBillSubmitted"), "Address proof (electricity bill) has not been submitted.");
|
||||
require(intOr(app, "monthlyIncome", 0) > 0, "Monthly income has not been assessed.");
|
||||
for (final JsonElement element : arr(app, "productDocuments")) {
|
||||
final JsonObject doc = element.getAsJsonObject();
|
||||
require(bool(doc, "submitted"), "Required document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||
}
|
||||
|
||||
advance(app, actor(input), "KYC Complete", strOr(input, "remarks", "KYC and document collection complete."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── CIC / Credit Bureau ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public String completeCicCheck(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "CIC_CHECK");
|
||||
|
||||
// RBI's Fair Practices Code requires the borrower's written consent before a bureau pull, and
|
||||
// the bureau's per-pull fee has to have actually been collected.
|
||||
require(bool(app, "cicConsentSigned"), "The borrower's signed credit-bureau consent is not on file.");
|
||||
require(bool(app, "cicChargeCollected"), "The credit bureau check charge has not been collected.");
|
||||
require(bool(app, "cicReportUploaded"), "No credit bureau report has been uploaded.");
|
||||
|
||||
advance(app, actor(input), "CIC Check Complete",
|
||||
strOr(input, "remarks", "Credit bureau report obtained and reviewed. Score " + intOr(app, "cibilScore", 0) + "."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── Field Investigation ──────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Marks one field-investigation check verified. The GPS fix and site photographs are checked
|
||||
* against what is stored, which is the point: "I visited" with nothing recorded is exactly what a
|
||||
* field-investigation control exists to prevent.
|
||||
*/
|
||||
@Transactional
|
||||
public String verifyFieldCheck(final String applicationNo, final String checkKey, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "FIELD_INVESTIGATION");
|
||||
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
|
||||
|
||||
final JsonObject check = objOrCreate(app, checkKey);
|
||||
require(filled(check, "officerAssigned"), "No field officer has been assigned to this visit.");
|
||||
require(filled(check, "latitude") && filled(check, "longitude"), "The visit has no GPS coordinates recorded.");
|
||||
require(intOr(check, "photoCount", 0) > 0, "No site photograph has been captured for this visit.");
|
||||
|
||||
check.addProperty("status", "VERIFIED");
|
||||
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
|
||||
check.addProperty("verifiedDate", today());
|
||||
check.addProperty("remarks", strOr(input, "remarks", "Visited and confirmed correct."));
|
||||
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String rejectFieldCheck(final String applicationNo, final String checkKey, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "FIELD_INVESTIGATION");
|
||||
require(FI_CHECK_KEYS.contains(checkKey), "Unknown field investigation check: " + checkKey);
|
||||
// A failed visit must say why -- it is what the Personal Discussion stage then works from.
|
||||
final String remarks = strOr(input, "remarks", "");
|
||||
require(!remarks.isBlank(), "A reason is required when a field visit fails.");
|
||||
|
||||
final JsonObject check = objOrCreate(app, checkKey);
|
||||
check.addProperty("status", "FAILED");
|
||||
check.addProperty("verifiedBy", strOr(input, "verifiedBy", strOr(check, "officerAssigned", "Field Officer")));
|
||||
check.addProperty("verifiedDate", today());
|
||||
check.addProperty("remarks", remarks);
|
||||
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String completeFieldInvestigation(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "FIELD_INVESTIGATION");
|
||||
requireFieldInvestigationComplete(app);
|
||||
|
||||
advance(app, actor(input), "FI Complete", strOr(input, "remarks", "Field investigation complete on all applicable checks."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── Personal Discussion ──────────────────────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public String clearPersonalDiscussion(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "PERSONAL_DISCUSSION");
|
||||
require(bool(app, "meetingScheduled"), "The personal discussion meeting has not been scheduled.");
|
||||
|
||||
final String remarks = strOr(input, "remarks", "No discrepancy found — cleared to proceed to Loan Committee.");
|
||||
app.addProperty("discrepancyType", "NONE");
|
||||
app.addProperty("overallStatus", "IN_PROGRESS");
|
||||
app.addProperty("personalDiscussionDate", today());
|
||||
app.addProperty("personalDiscussionNotes", remarks);
|
||||
app.addProperty("declarationTaken", true);
|
||||
app.addProperty("declarationDate", today());
|
||||
|
||||
advance(app, actor(input), "Cleared", remarks);
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String flagDiscrepancy(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "PERSONAL_DISCUSSION");
|
||||
|
||||
final String type = strOr(input, "discrepancyType", "");
|
||||
require(!type.isBlank() && !"NONE".equals(type), "A discrepancy type is required.");
|
||||
final String notes = strOr(input, "remarks", "");
|
||||
require(!notes.isBlank(), "Notes are required when flagging a discrepancy.");
|
||||
|
||||
app.addProperty("discrepancyType", type);
|
||||
app.addProperty("personalDiscussionDate", today());
|
||||
app.addProperty("personalDiscussionNotes", notes);
|
||||
// Held rather than rejected: a discrepancy is resolvable, and the application has to stay
|
||||
// visible as outstanding work rather than dropping out of the pipeline.
|
||||
app.addProperty("overallStatus", "ON_HOLD");
|
||||
|
||||
appendHistory(app, str(app, "currentStage"), actor(input), "Discrepancy Flagged", notes);
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String resolveDiscrepancy(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "PERSONAL_DISCUSSION");
|
||||
require("ON_HOLD".equals(str(app, "overallStatus")), "This application is not on hold.");
|
||||
|
||||
final String remarks = strOr(input, "remarks", "Discrepancy resolved on further review — cleared to proceed.");
|
||||
app.addProperty("discrepancyType", "NONE");
|
||||
app.addProperty("overallStatus", "IN_PROGRESS");
|
||||
app.addProperty("personalDiscussionNotes", remarks);
|
||||
app.addProperty("declarationTaken", true);
|
||||
app.addProperty("declarationDate", today());
|
||||
|
||||
advance(app, actor(input), "Resolved", remarks);
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── Approvals ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Records a Credit Committee / Risk / Legal / Post-Check decision.
|
||||
*
|
||||
* @param field one of creditCommitteeApproval, riskApproval, legalApproval, postCheckApproval
|
||||
*/
|
||||
@Transactional
|
||||
public String decideApproval(final String applicationNo, final String field, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
|
||||
final String decision = strOr(input, "decision", "APPROVED");
|
||||
require(List.of("APPROVED", "REJECTED", "EXCEPTION_APPROVED").contains(decision), "Unknown decision: " + decision);
|
||||
|
||||
final String expectedStage = switch (field) {
|
||||
case "creditCommitteeApproval" -> "CREDIT_COMMITTEE";
|
||||
case "riskApproval" -> "RISK_APPROVAL";
|
||||
case "legalApproval" -> "LEGAL_APPROVAL";
|
||||
case "postCheckApproval" -> "POST_CHECK";
|
||||
default -> throw badRequest("Unknown approval stage: " + field);
|
||||
};
|
||||
requireStage(app, expectedStage);
|
||||
|
||||
// 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.
|
||||
if (!"REJECTED".equals(decision)) {
|
||||
switch (field) {
|
||||
case "creditCommitteeApproval" -> requireCommitteeReportsReady(app, input);
|
||||
case "riskApproval" -> requireRiskChecklistReady(app);
|
||||
case "legalApproval" -> requireLegalChecklistDone(app);
|
||||
case "postCheckApproval" -> requirePostCheckSignatures(app);
|
||||
default -> throw badRequest("Unknown approval stage: " + field);
|
||||
}
|
||||
}
|
||||
|
||||
String remarks = strOr(input, "remarks", "REJECTED".equals(decision) ? "Declined on review." : "Reviewed and approved.");
|
||||
if ("creditCommitteeApproval".equals(field) && !"REJECTED".equals(decision)) {
|
||||
// The resolution number and date are what the sanction letter later quotes, so they are
|
||||
// recorded on the application and restated in the audit trail.
|
||||
final String resolutionNo = strOr(input, "resolutionNo", "");
|
||||
final String resolutionDate = strOr(input, "resolutionDate", "");
|
||||
app.addProperty("committeeResolutionNo", resolutionNo);
|
||||
app.addProperty("committeeResolutionDate", resolutionDate);
|
||||
remarks = remarks + " (Resolution No. " + resolutionNo + " dated " + resolutionDate + ")";
|
||||
}
|
||||
|
||||
final JsonObject approval = objOrCreate(app, field);
|
||||
approval.addProperty("decision", decision);
|
||||
approval.addProperty("approverName", strOr(input, "approverName", "Bank Staff"));
|
||||
approval.addProperty("approverRole", strOr(input, "approverRole", expectedStage));
|
||||
approval.addProperty("date", today());
|
||||
approval.addProperty("remarks", remarks);
|
||||
|
||||
if ("REJECTED".equals(decision)) {
|
||||
app.addProperty("overallStatus", "REJECTED");
|
||||
appendHistory(app, str(app, "currentStage"), actor(input), "Rejected", remarks);
|
||||
} else {
|
||||
advance(app, actor(input), "EXCEPTION_APPROVED".equals(decision) ? "Approved on Exception" : "Approved", remarks);
|
||||
}
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
// ── Signing / Cheque-ACH / Disbursement ──────────────────────────────────
|
||||
|
||||
@Transactional
|
||||
public String markSigned(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "CUSTOMER_SIGNING");
|
||||
require(filled(app, "agreementSigningSlot"), "No agreement signing slot has been recorded.");
|
||||
|
||||
app.addProperty("customerSigningDate", today());
|
||||
advance(app, strOr(input, "actionBy", strOr(app, "applicantName", "Customer")), "Signed",
|
||||
strOr(input, "remarks", "Loan agreement signed by the customer."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public String confirmChequeAch(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
requireStage(app, "CHEQUE_ACH");
|
||||
require(bool(app, "chequeReceived"), "Security cheques have not been received.");
|
||||
require(bool(app, "achSigned"), "The ACH/NACH mandate has not been signed.");
|
||||
|
||||
advance(app, actor(input), "Cheque & ACH Received",
|
||||
strOr(input, "remarks", "Security cheques received and ACH mandate signed."));
|
||||
return pipelineService.storeApplication(applicationNo, app);
|
||||
}
|
||||
|
||||
/**
|
||||
* Final disbursement. Requires every approval level to actually stand as approved on the stored
|
||||
* record — the last point at which an application could otherwise slip through with a rejected or
|
||||
* still-pending sign-off.
|
||||
*/
|
||||
@Transactional
|
||||
public String disburse(final String applicationNo, final String body) {
|
||||
final JsonObject app = pipelineService.loadApplication(applicationNo);
|
||||
final JsonObject input = parseObject(body);
|
||||
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 JsonObject approval = obj(app, field);
|
||||
final String decision = approval == null ? null : str(approval, "decision");
|
||||
require("APPROVED".equals(decision) || "EXCEPTION_APPROVED".equals(decision),
|
||||
"Cannot disburse: " + field + " is not approved.");
|
||||
}
|
||||
require(filled(app, "customerSigningDate"), "The customer has not signed the loan agreement.");
|
||||
|
||||
final String mode = strOr(input, "disbursementMode", "Account Credit");
|
||||
app.addProperty("disbursementDate", today());
|
||||
app.addProperty("disbursementMode", mode);
|
||||
if (!filled(app, "achConfirmationRef")) {
|
||||
final String suffix = applicationNo.length() >= 6 ? applicationNo.substring(applicationNo.length() - 6) : applicationNo;
|
||||
app.addProperty("achConfirmationRef", "ACH" + suffix);
|
||||
}
|
||||
app.addProperty("emiActivationConfirmed", true);
|
||||
app.addProperty("emiActivationDate", today());
|
||||
app.addProperty("overallStatus", "DISBURSED");
|
||||
if (LoanOriginationJson.present(input, "fineractLoanId")) {
|
||||
app.addProperty("fineractLoanId", LoanOriginationJson.lng(input, "fineractLoanId"));
|
||||
app.addProperty("fineractLoanAccountNo", str(input, "fineractLoanAccountNo"));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// ── Shared checklist rules (mirror the Application Detail screen's gating) ─
|
||||
|
||||
private void requireFieldInvestigationComplete(final JsonObject app) {
|
||||
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||
requireCheckVerified(app, "officeVerification", "Office/employment verification");
|
||||
if (bool(app, "secured")) {
|
||||
requireCheckVerified(app, "propertyVerification", "Property verification");
|
||||
}
|
||||
if (bool(app, "hasShop")) {
|
||||
requireCheckVerified(app, "shopVerification", "Shop verification");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireCheckVerified(final JsonObject app, final String key, final String label) {
|
||||
final JsonObject check = obj(app, key);
|
||||
require(check != null && "VERIFIED".equals(str(check, "status")), label + " is not verified.");
|
||||
}
|
||||
|
||||
private void requireCommitteeReportsReady(final JsonObject app, final JsonObject input) {
|
||||
require(bool(app, "cicReportUploaded"), "The credit bureau report is not on file.");
|
||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "cersaiSearchDone"), "The CERSAI search has not been done.");
|
||||
}
|
||||
require(bool(app, "aadhaarVerified"), "Aadhaar has not been verified.");
|
||||
require(bool(app, "panVerified"), "PAN has not been verified.");
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
requireFieldInvestigationComplete(app);
|
||||
require(bool(app, "managerMeetingDone"), "The manager meeting has not been recorded.");
|
||||
require(!strOr(input, "resolutionNo", "").isBlank(), "A committee resolution number is required.");
|
||||
require(!strOr(input, "resolutionDate", "").isBlank(), "A committee resolution date is required.");
|
||||
}
|
||||
|
||||
private void requireRiskChecklistReady(final JsonObject app) {
|
||||
requireCheckVerified(app, "addressVerification", "Address verification");
|
||||
require(bool(app, "incomeProofSubmitted"), "Income proof has not been submitted.");
|
||||
require(bool(app, "previousLoanDeclarationDone"), "The previous-loan declaration has not been taken.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "propertySearchReportDone"), "The property search report is not on file.");
|
||||
require(bool(app, "valuationReportConfirmed"), "The valuation report has not been confirmed.");
|
||||
}
|
||||
requireFieldInvestigationComplete(app);
|
||||
require(bool(app, "backgroundCheckDone"), "The background check has not been done.");
|
||||
if (bool(app, "guarantorRequired")) {
|
||||
// A guarantor at a co-operative bank has to be an existing member, not just a name: an
|
||||
// onboarded client id AND a live share account.
|
||||
require(LoanOriginationJson.lng(app, "guarantorClientId") != null,
|
||||
"A guarantor is required but no existing client has been selected.");
|
||||
require(bool(app, "guarantorHasShareAccount"), "The selected guarantor has no active share account.");
|
||||
}
|
||||
}
|
||||
|
||||
private void requireLegalChecklistDone(final JsonObject app) {
|
||||
require(bool(app, "feesAndAccountOpened"), "Fees and account opening are not complete.");
|
||||
if (bool(app, "secured")) {
|
||||
require(bool(app, "mortgageCreated"), "The mortgage has not been created.");
|
||||
}
|
||||
require(bool(app, "legalStampDone"), "Legal stamping is not complete.");
|
||||
require(bool(app, "fileFilingDone"), "File filing is not complete.");
|
||||
for (final JsonElement element : arr(app, "legalDocuments")) {
|
||||
final JsonObject doc = element.getAsJsonObject();
|
||||
require(bool(doc, "submitted"), "Legal document not submitted: " + strOr(doc, "label", strOr(doc, "key", "document")));
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePostCheckSignatures(final JsonObject app) {
|
||||
require(bool(app, "signedByAgent"), "The agent has not signed off.");
|
||||
require(bool(app, "signedByManager"), "The manager has not signed off.");
|
||||
require(bool(app, "signedByCommittee"), "The committee has not signed off.");
|
||||
require(bool(app, "signedByRiskApproval"), "Risk approval has not signed off.");
|
||||
require(bool(app, "signedByLegalApproval"), "Legal approval has not signed off.");
|
||||
}
|
||||
|
||||
// ── Stage / history mechanics ────────────────────────────────────────────
|
||||
|
||||
private void requireStage(final JsonObject app, final String expected) {
|
||||
final String actual = str(app, "currentStage");
|
||||
if (!expected.equals(actual)) {
|
||||
throw badRequest("This action applies at the " + expected + " stage, but the application is at " + actual + ".");
|
||||
}
|
||||
final String status = str(app, "overallStatus");
|
||||
if ("REJECTED".equals(status)) {
|
||||
throw badRequest("This application has been rejected and cannot be progressed.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the application to the next stage and appends the audit entry. {@code furthestStageIndex}
|
||||
* only ever increases, so jumping back to an earlier stage for a correction does not make
|
||||
* already-completed stages look unfinished.
|
||||
*/
|
||||
private void advance(final JsonObject app, final String actionBy, final String outcome, final String remarks) {
|
||||
final String current = str(app, "currentStage");
|
||||
appendHistory(app, current, actionBy, outcome, remarks);
|
||||
|
||||
final int index = STAGE_ORDER.indexOf(current);
|
||||
if (index >= 0 && index < STAGE_ORDER.size() - 1) {
|
||||
final int next = index + 1;
|
||||
app.addProperty("currentStage", STAGE_ORDER.get(next));
|
||||
app.addProperty("furthestStageIndex", Math.max(intOr(app, "furthestStageIndex", 0), next));
|
||||
}
|
||||
}
|
||||
|
||||
private void appendHistory(final JsonObject app, final String stage, final String actionBy, final String outcome,
|
||||
final String remarks) {
|
||||
final JsonArray history = arrOrCreate(app, "stageHistory");
|
||||
final JsonObject entry = new JsonObject();
|
||||
entry.addProperty("stage", stage);
|
||||
entry.addProperty("date", today());
|
||||
entry.addProperty("actionBy", actionBy);
|
||||
entry.addProperty("outcome", outcome);
|
||||
entry.addProperty("remarks", remarks);
|
||||
history.add(entry);
|
||||
}
|
||||
|
||||
private String actor(final JsonObject input) {
|
||||
return strOr(input, "actionBy", "Bank Staff");
|
||||
}
|
||||
|
||||
private String today() {
|
||||
final LocalDate businessDate = DateUtils.getBusinessLocalDate();
|
||||
return (businessDate == null ? LocalDate.now(DateUtils.getDateTimeZoneOfTenant()) : businessDate).format(WORKFLOW_DATE);
|
||||
}
|
||||
|
||||
private void require(final boolean condition, final String message) {
|
||||
if (!condition) {
|
||||
throw badRequest(message);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneralPlatformDomainRuleException badRequest(final String message) {
|
||||
return new GeneralPlatformDomainRuleException("error.msg.loan.origination.stage.invalid", message);
|
||||
}
|
||||
|
||||
/** Kept for symmetry with the check-key list above; used by callers validating a key up front. */
|
||||
public static boolean isKnownFieldCheck(final String checkKey) {
|
||||
return FI_CHECK_KEYS.contains(checkKey);
|
||||
}
|
||||
|
||||
/** Exposed so the API layer can advertise the stage order without duplicating it. */
|
||||
public static List<String> stageOrder() {
|
||||
return Arrays.asList(STAGE_ORDER.toArray(new String[0]));
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ fineract.security.oauth2.client.registrations.frontend-client.require-authorizat
|
||||
fineract.tenant.host=${FINERACT_DEFAULT_TENANTDB_HOSTNAME:localhost}
|
||||
fineract.tenant.port=${FINERACT_DEFAULT_TENANTDB_PORT:5432}
|
||||
fineract.tenant.username=${FINERACT_DEFAULT_TENANTDB_UID:postgres}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:anshul@9009}
|
||||
fineract.tenant.password=${FINERACT_DEFAULT_TENANTDB_PWD:313863}
|
||||
fineract.tenant.parameters=${FINERACT_DEFAULT_TENANTDB_CONN_PARAMS:}
|
||||
fineract.tenant.timezone=${FINERACT_DEFAULT_TENANTDB_TIMEZONE:Asia/Kolkata}
|
||||
fineract.tenant.identifier=${FINERACT_DEFAULT_TENANTDB_IDENTIFIER:default}
|
||||
@@ -181,9 +181,9 @@ fineract.loan.transactionprocessor.error-not-found-fail=${FINERACT_LOAN_TRANSACT
|
||||
fineract.loan.status-change-history-statuses=${FINERACT_LOAN_STATUS_CHANGE_HISTORY_STATUSES:NONE}
|
||||
|
||||
fineract.content.regex-whitelist-enabled=${FINERACT_CONTENT_REGEX_WHITELIST_ENABLED:true}
|
||||
fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png,.*\\.zip$}
|
||||
fineract.content.regex-whitelist=${FINERACT_CONTENT_REGEX_WHITELIST:.*\\.pdf$,.*\\.doc,.*\\.docx,.*\\.xls,.*\\.xlsx,.*\\.jpg,.*\\.jpeg,.*\\.png,.*\\.zip$,.*\\.csv$}
|
||||
fineract.content.mime-whitelist-enabled=${FINERACT_CONTENT_MIME_WHITELIST_ENABLED:true}
|
||||
fineract.content.mime-whitelist=${FINERACT_CONTENT_MIME_WHITELIST:application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png,application/zip,application/x-zip-compressed,application/octet-stream}
|
||||
fineract.content.mime-whitelist=${FINERACT_CONTENT_MIME_WHITELIST:application/pdf,application/msword,application/vnd.openxmlformats-officedocument.wordprocessingml.document,application/vnd.ms-excel,application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,image/jpeg,image/png,application/zip,application/x-zip-compressed,application/octet-stream,text/csv}
|
||||
fineract.content.default-buffer-size=${FINERACT_CONTENT_DEFAULT_BUFFER_SIZE:8192}
|
||||
fineract.content.filesystem.enabled=${FINERACT_CONTENT_FILESYSTEM_ENABLED:true}
|
||||
fineract.content.filesystem.rootFolder=${FINERACT_CONTENT_FILESYSTEM_ROOT_FOLDER:${user.home}/.fineract}
|
||||
@@ -407,7 +407,7 @@ server.tomcat.mbeanregistry.enabled=${FINERACT_SERVER_TOMCAT_MBEANREGISTRY_ENABL
|
||||
spring.datasource.hikari.driverClassName=${FINERACT_HIKARI_DRIVER_SOURCE_CLASS_NAME:org.postgresql.Driver}
|
||||
spring.datasource.hikari.jdbcUrl=${FINERACT_HIKARI_JDBC_URL:jdbc:postgresql://localhost:5432/fineract_tenants}
|
||||
spring.datasource.hikari.username=${FINERACT_HIKARI_USERNAME:postgres}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:anshul@9009}
|
||||
spring.datasource.hikari.password=${FINERACT_HIKARI_PASSWORD:313863}
|
||||
spring.datasource.hikari.minimumIdle=${FINERACT_HIKARI_MINIMUM_IDLE:3}
|
||||
spring.datasource.hikari.maximumPoolSize=${FINERACT_HIKARI_MAXIMUM_POOL_SIZE:10}
|
||||
spring.datasource.hikari.idleTimeout=${FINERACT_HIKARI_IDLE_TIMEOUT:60000}
|
||||
|
||||
@@ -532,4 +532,5 @@
|
||||
<include file="parts/0463_add_authorized_signatory_client_ids_to_client_non_person.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0464_add_sanctions_alert_disposition.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0465_add_dip_override_and_client_status.xml" relativeToChangelogFile="true"/>
|
||||
<include file="parts/0465_create_loan_origination_pipeline.xml" relativeToChangelogFile="true"/>
|
||||
</databaseChangeLog>
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
<?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">
|
||||
|
||||
<!--
|
||||
Loan Origination Pipeline — the pre-disbursement sanctioning workflow every loan application
|
||||
walks through (Application → KYC/OVD → CIC Credit Bureau Check → Field Investigation →
|
||||
Personal Discussion → Credit Committee → Risk Approval → Legal Approval → Customer Signing →
|
||||
Cheque/ACH → Post-Disbursement Check → Disbursed). Distinct from m_loan: this is the
|
||||
*decisioning* record that exists before (and decides whether) a real loan account is ever
|
||||
created, so it deliberately does not FK to m_loan except through fineract_loan_id once the
|
||||
account is finally opened at disbursement.
|
||||
|
||||
Storage shape is a deliberate hybrid, and the three parts are disjoint (nothing stored twice):
|
||||
- the dozen fields the Pipeline list / Channel-BC-Share reports / Target-vs-Actual actually
|
||||
filter and aggregate on are real indexed columns, so those stay plain SQL;
|
||||
- every other scalar field lives in `payload` as JSON. That bucket is "whatever else the
|
||||
application object carries", not an allowlist, so a field added to the workflow later is
|
||||
persisted with no schema or code change — the workflow has been gaining fields steadily
|
||||
and an allowlist would silently drop them;
|
||||
- the genuinely repeating parts (stage history, the four approvals, the four field-
|
||||
investigation checks, the document checklists) are normalised into child tables below,
|
||||
because they are queried across applications (e.g. everything one officer approved) and
|
||||
an append-only stage history is an audit trail a co-operative bank has to be able to
|
||||
produce on demand.
|
||||
-->
|
||||
<changeSet id="0465_create_loan_origination_pipeline" author="CBS">
|
||||
|
||||
<createTable tableName="m_loan_origination_application">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="application_no" type="VARCHAR(40)">
|
||||
<constraints nullable="false" unique="true"
|
||||
uniqueConstraintName="uq_loan_origination_application_no"/>
|
||||
</column>
|
||||
<column name="applicant_name" type="VARCHAR(200)"/>
|
||||
<column name="client_id" type="BIGINT"/>
|
||||
<column name="branch_code" type="VARCHAR(10)"/>
|
||||
<column name="channel" type="VARCHAR(20)"/>
|
||||
<column name="bc_agent_name" type="VARCHAR(120)"/>
|
||||
<column name="product_code" type="VARCHAR(10)"/>
|
||||
<column name="requested_amount" type="DECIMAL(19,6)"/>
|
||||
<column name="current_stage" type="VARCHAR(40)"/>
|
||||
<column name="overall_status" type="VARCHAR(20)"/>
|
||||
<column name="is_share_member" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="fineract_loan_id" type="BIGINT"/>
|
||||
<!-- Every remaining scalar field of the application object, as JSON. TEXT (not JSONB)
|
||||
to match how this codebase already stores JSON (m_permission command_as_json,
|
||||
OtpLog request_payload) and to stay portable across MySQL/PostgreSQL. -->
|
||||
<column name="payload" type="TEXT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- Parsed credit-bureau report: deeply nested (accounts -> dpd grid -> monthly cells),
|
||||
read-only once parsed, and only ever rendered as a whole. Kept as its own JSON
|
||||
column rather than exploded into four more tables nothing would ever query. -->
|
||||
<column name="cic_parsed_analysis" type="TEXT"/>
|
||||
<column name="created_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="last_modified_date" type="TIMESTAMP" defaultValueComputed="CURRENT_TIMESTAMP">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<createIndex tableName="m_loan_origination_application" indexName="idx_loi_app_current_stage">
|
||||
<column name="current_stage"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_loan_origination_application" indexName="idx_loi_app_overall_status">
|
||||
<column name="overall_status"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_loan_origination_application" indexName="idx_loi_app_branch_code">
|
||||
<column name="branch_code"/>
|
||||
</createIndex>
|
||||
<createIndex tableName="m_loan_origination_application" indexName="idx_loi_app_client_id">
|
||||
<column name="client_id"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- Append-only audit trail: one row per stage transition / action taken. -->
|
||||
<createTable tableName="m_loan_origination_stage_history">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="application_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="seq_no" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="stage" type="VARCHAR(40)"/>
|
||||
<column name="action_date" type="VARCHAR(20)"/>
|
||||
<column name="action_by" type="VARCHAR(120)"/>
|
||||
<column name="outcome" type="VARCHAR(60)"/>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_loan_origination_stage_history" baseColumnNames="application_id"
|
||||
constraintName="fk_loi_stage_history_application"
|
||||
referencedTableName="m_loan_origination_application" referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<createIndex tableName="m_loan_origination_stage_history" indexName="idx_loi_stage_history_application">
|
||||
<column name="application_id"/>
|
||||
</createIndex>
|
||||
|
||||
<!-- One row per approval level: creditCommitteeApproval / riskApproval / legalApproval /
|
||||
postCheckApproval. Unique per application so an approval is updated, never duplicated. -->
|
||||
<createTable tableName="m_loan_origination_approval">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="application_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="approval_field" type="VARCHAR(40)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="decision" type="VARCHAR(30)"/>
|
||||
<column name="approver_name" type="VARCHAR(120)"/>
|
||||
<column name="approver_role" type="VARCHAR(120)"/>
|
||||
<column name="decision_date" type="VARCHAR(20)"/>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_loan_origination_approval" baseColumnNames="application_id"
|
||||
constraintName="fk_loi_approval_application"
|
||||
referencedTableName="m_loan_origination_application" referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<addUniqueConstraint tableName="m_loan_origination_approval" columnNames="application_id, approval_field"
|
||||
constraintName="uq_loi_approval_application_field"/>
|
||||
|
||||
<!-- One row per Field Investigation check: address / office / property / shop. -->
|
||||
<createTable tableName="m_loan_origination_fi_check">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="application_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="check_key" type="VARCHAR(40)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="status" type="VARCHAR(20)"/>
|
||||
<column name="verified_by" type="VARCHAR(120)"/>
|
||||
<column name="verified_date" type="VARCHAR(20)"/>
|
||||
<column name="remarks" type="VARCHAR(1000)"/>
|
||||
<column name="officer_assigned" type="VARCHAR(120)"/>
|
||||
<column name="assigned_date" type="VARCHAR(20)"/>
|
||||
<!-- Epoch millis the visit was assigned — drives the 48h SLA clock on the FI screen. -->
|
||||
<column name="assigned_at" type="BIGINT"/>
|
||||
<column name="latitude" type="VARCHAR(40)"/>
|
||||
<column name="longitude" type="VARCHAR(40)"/>
|
||||
<column name="photo_count" type="INT" defaultValueNumeric="0"/>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_loan_origination_fi_check" baseColumnNames="application_id"
|
||||
constraintName="fk_loi_fi_check_application"
|
||||
referencedTableName="m_loan_origination_application" referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<addUniqueConstraint tableName="m_loan_origination_fi_check" columnNames="application_id, check_key"
|
||||
constraintName="uq_loi_fi_check_application_key"/>
|
||||
|
||||
<!-- Both document checklists in one table, discriminated by doc_kind: the product-specific
|
||||
KYC/collateral papers (PRODUCT) and the Legal Approval paperwork (LEGAL). Officers can
|
||||
add their own entries on the spot, hence is_custom. -->
|
||||
<createTable tableName="m_loan_origination_document">
|
||||
<column name="id" type="BIGINT" autoIncrement="true">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="application_id" type="BIGINT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="doc_kind" type="VARCHAR(20)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="doc_key" type="VARCHAR(60)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="label" type="VARCHAR(200)"/>
|
||||
<column name="hint" type="VARCHAR(500)"/>
|
||||
<column name="is_custom" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="submitted" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="seq_no" type="INT">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<addForeignKeyConstraint baseTableName="m_loan_origination_document" baseColumnNames="application_id"
|
||||
constraintName="fk_loi_document_application"
|
||||
referencedTableName="m_loan_origination_application" referencedColumnNames="id"
|
||||
onDelete="CASCADE"/>
|
||||
<createIndex tableName="m_loan_origination_document" indexName="idx_loi_document_application">
|
||||
<column name="application_id"/>
|
||||
</createIndex>
|
||||
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
Reference in New Issue
Block a user