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

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:
2026-08-17 17:12:43 +05:30
co-authored by Claude Sonnet 5
parent a2fa8688ae
commit 14b2585cf5
8 changed files with 1574 additions and 5 deletions
@@ -18,6 +18,7 @@
*/
package org.apache.fineract.infrastructure.contentstore.policy;
import java.util.Set;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.Strings;
@@ -31,6 +32,10 @@ import org.springframework.stereotype.Component;
@Component
public class MimeContentPolicy implements ContentPolicy {
// CSV has no content-level magic bytes, so byte-sniffing a delimited text file legitimately
// detects it as text/plain even though it was uploaded (and named) as text/csv.
private static final Set<String> INTERCHANGEABLE_TEXT_MIME_TYPES = Set.of("text/csv", "text/plain");
private final ContentDetectorManager contentDetectorManager;
@Override
@@ -39,7 +44,11 @@ public class MimeContentPolicy implements ContentPolicy {
var result = contentDetectorManager
.detect(ContentDetectorContext.builder().inputStream(ctx.getInputStream()).inputStreamEnabled(true).build());
if (!Strings.CI.equals(result.getMimeType(), ctx.getMimeType())) {
boolean matches = Strings.CI.equals(result.getMimeType(), ctx.getMimeType())
|| (INTERCHANGEABLE_TEXT_MIME_TYPES.contains(result.getMimeType())
&& INTERCHANGEABLE_TEXT_MIME_TYPES.contains(ctx.getMimeType()));
if (!matches) {
throw new ContentPolicyException(String.format("Detected file type (%s), but mime type (%s) was provided. Mismatch!",
result.getMimeType(), ctx.getMimeType()));
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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();
}
}
@@ -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>
@@ -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>