feat(loan-origination): persist the sanctioning pipeline to the backend
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

Replaces the in-memory mock (LOAN_APPLICATIONS array + seeded RNG, reset on
every refresh) with real Fineract-backed persistence: a new
LoanOriginationService for CRUD plus one API call per stage transition, a
loan-fineract-bridge that creates a real loan account (create + approve +
disburse) at disbursement for Existing Customers, and file uploads through
Fineract's generic document store keyed by a stable checklist slot so
"On file"/View controls survive a reload.

Also fixes several gaps found while verifying full-cycle persistence:
- CIC charge-account selection never saved (missing persist() call)
- Co-borrower relationship/monthly income lived in local-only component state
  instead of on the application, so they reset on refresh
- Field Investigation "View Full Details" showed local-session photos only;
  stored photos are now downloaded and shown as real thumbnails
- "Assign Field Officer" was free text; now a dropdown of real CBS loan
  officers pulled from /staff
- site photos and cheque/signature images now open in the same zoomable,
  pannable lightgallery viewer used elsewhere in the app

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-17 17:05:05 +05:30
co-authored by Claude Sonnet 5
parent 2d93f80d50
commit c18219fce5
10 changed files with 1333 additions and 301 deletions
@@ -181,6 +181,9 @@ export interface StageHistoryEntry {
}
export interface LoanApplication {
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against
* the application's entity id), so it's absent only on an application not yet persisted. */
id?: number;
applicationNo: string;
applicationDate: string;
applicantName: string;
@@ -225,6 +228,12 @@ export interface LoanApplication {
eligibleLoanAmount: number;
coBorrowerRequired: boolean;
coBorrowerName: string;
/** Officer's voluntary opt-in to add a co-borrower even when `coBorrowerRequired` hasn't fired
* on its own -- separate from that flag so a required co-borrower stays flagged even if this
* is later unticked. */
coBorrowerWanted: boolean;
coBorrowerRelationship: string;
coBorrowerMonthlyIncome: number;
// CIC / Credit Bureau check — mandatory before sanction under the CICRA, 2005 framework.
/** RBI's Fair Practices Code requires the customer's informed consent before a credit bureau
@@ -354,6 +363,14 @@ export interface LoanApplication {
achConfirmationRef: string;
emiActivationConfirmed: boolean;
emiActivationDate: string;
/** Set only when a real Fineract loan account was actually created at disbursement (Existing
* Customer applications only, see loan-fineract-bridge.ts) -- null for every mock application
* and for New Customer applications, which disburse mock-only. */
fineractLoanId: number | null;
fineractLoanAccountNo: string | null;
/** Last error from attempting real loan creation, so the officer can see why and retry --
* cleared on every fresh attempt, never fabricated as a success. */
fineractLoanError: string | null;
currentStage: OriginationStage;
/** The furthest stage this application has ever reached, as a STAGE_ORDER index -- kept
@@ -936,6 +953,9 @@ function generateApplications(count: number): LoanApplication[] {
eligibleLoanAmount,
coBorrowerRequired: eligibleLoanAmount < requestedAmount,
coBorrowerName: '—',
coBorrowerWanted: false,
coBorrowerRelationship: '',
coBorrowerMonthlyIncome: 0,
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
@@ -1011,6 +1031,9 @@ function generateApplications(count: number): LoanApplication[] {
achConfirmationRef: '—',
emiActivationConfirmed: false,
emiActivationDate: '—',
fineractLoanId: null,
fineractLoanAccountNo: null,
fineractLoanError: null,
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',
@@ -1388,9 +1411,15 @@ function generateApplications(count: number): LoanApplication[] {
return apps;
}
// Sized like a real cooperative bank's active sanctioning pipeline — enough to need filters
// and pagination, not so many it stops reading as "an active queue".
export const LOAN_APPLICATIONS: LoanApplication[] = generateApplications(140);
/** Builds the demo book that seeds an empty pipeline on first use — sized like a real cooperative
* bank's active sanctioning queue: enough to need filters and pagination, not so many it stops
* reading as "an active queue".
*
* Called once, to POST to the seed endpoint. Applications are read back from the database after
* that, so this is a one-time generator and no longer the live source of truth. */
export function generateSeedApplications(count = 140): LoanApplication[] {
return generateApplications(count);
}
// -------------------------------------------------------------------------
// Runtime actions — every button in the Application Detail screen calls one of these to mutate
@@ -1787,7 +1816,7 @@ export function buildEmiSchedule(
// -------------------------------------------------------------------------
// New Application intake — the Pipeline screen's "New Application" form builds one of these and
// appends it to LOAN_APPLICATIONS, exactly at the APPLICATION stage with everything else pending.
// posts it to the database, exactly at the APPLICATION stage with everything else pending.
// -------------------------------------------------------------------------
export interface NewApplicationInput {
channel: Channel;
@@ -1839,6 +1868,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
eligibleLoanAmount: 0,
coBorrowerRequired: false,
coBorrowerName: '—',
coBorrowerWanted: false,
coBorrowerRelationship: '',
coBorrowerMonthlyIncome: 0,
cicConsentSigned: false,
cicChargeCollected: false,
cicChargeMode: null,
@@ -1914,6 +1946,9 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
achConfirmationRef: '—',
emiActivationConfirmed: false,
emiActivationDate: '—',
fineractLoanId: null,
fineractLoanAccountNo: null,
fineractLoanError: null,
currentStage: 'APPLICATION',
furthestStageIndex: 0,
overallStatus: 'IN_PROGRESS',