Files
cbsnexora/src/app/loan-origination/loan-origination.service.ts
T
niteshandClaude Sonnet 5 c18219fce5
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
feat(loan-origination): persist the sanctioning pipeline to the backend
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>
2026-08-17 17:05:05 +05:30

202 lines
8.6 KiB
TypeScript

/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
import { LoanApplication } from './loan-origination-data';
/**
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
*
* `save()` sends the whole application object. That covers the dozens of inline fields an officer
* types across the stages: there is no field list to keep in step with the model, so a field added
* later persists without touching this service or the backend.
*
* The stage methods (`completeKyc`, `decideApproval`, `verifyFieldCheck`, `disburse`, …) send only
* that step's own inputs and let the server apply the transition. Sanctioning decisions and
* verifications go through those instead of `save()` because the client should not be able to simply
* assert them — the server checks each stage's prerequisites against the stored record first.
*/
@Injectable({ providedIn: 'root' })
export class LoanOriginationService {
private readonly http = inject(HttpClient);
private readonly baseUrl = '/loan-origination/applications';
list(): Observable<LoanApplication[]> {
return this.http.get<LoanApplication[]>(this.baseUrl);
}
get(applicationNo: string): Observable<LoanApplication> {
return this.http.get<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}`);
}
create(application: LoanApplication): Observable<LoanApplication> {
return this.http.post<LoanApplication>(this.baseUrl, application);
}
save(application: LoanApplication): Observable<LoanApplication> {
return this.http.put<LoanApplication>(
`${this.baseUrl}/${encodeURIComponent(application.applicationNo)}`,
application
);
}
/** Seeds the demo book on first use. The backend ignores this once the pipeline is non-empty, so
* calling it again after a reload can't duplicate anything. */
seed(applications: LoanApplication[]): Observable<{ seeded: number }> {
return this.http.post<{ seeded: number }>(`${this.baseUrl}/seed`, applications);
}
// -------------------- Stage transitions --------------------
// Each of these posts only its own step's inputs and lets the server validate that stage's
// prerequisites against the stored record before applying the change. The response is the updated
// application, which the caller uses in place of its own copy -- so the screen always shows what
// the server actually accepted rather than what the client assumed.
//
// The full-object save() above is still used for inline field edits; these cover the decisions
// (approvals, verifications, disbursement) where "the client said so" is not good enough.
private stage(applicationNo: string, path: string, body: unknown = {}): Observable<LoanApplication> {
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
}
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
}
completeCicCheck(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'cic-complete', { actionBy, remarks });
}
verifyFieldCheck(
applicationNo: string,
checkKey: string,
verifiedBy: string,
remarks?: string
): Observable<LoanApplication> {
return this.stage(applicationNo, `field-investigation/${checkKey}/verify`, { verifiedBy, remarks });
}
rejectFieldCheck(
applicationNo: string,
checkKey: string,
verifiedBy: string,
remarks: string
): Observable<LoanApplication> {
return this.stage(applicationNo, `field-investigation/${checkKey}/reject`, { verifiedBy, remarks });
}
completeFieldInvestigation(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'field-investigation/complete', { actionBy, remarks });
}
clearPersonalDiscussion(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'personal-discussion/clear', { actionBy, remarks });
}
flagDiscrepancy(
applicationNo: string,
actionBy: string,
discrepancyType: string,
remarks: string
): Observable<LoanApplication> {
return this.stage(applicationNo, 'personal-discussion/flag', { actionBy, discrepancyType, remarks });
}
resolveDiscrepancy(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'personal-discussion/resolve', { actionBy, remarks });
}
decideApproval(
applicationNo: string,
field: string,
body: {
decision: 'APPROVED' | 'REJECTED' | 'EXCEPTION_APPROVED';
approverName: string;
approverRole: string;
remarks?: string;
resolutionNo?: string;
resolutionDate?: string;
}
): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}`, body);
}
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'signing', { actionBy });
}
confirmChequeAch(applicationNo: string, actionBy: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'cheque-ach', { actionBy });
}
disburse(
applicationNo: string,
actionBy: string,
disbursementMode: string,
fineractLoan?: { fineractLoanId: number; fineractLoanAccountNo: string }
): Observable<LoanApplication> {
return this.stage(applicationNo, 'disburse', { actionBy, disbursementMode, ...(fineractLoan ?? {}) });
}
/** Field officers to assign during Field Investigation, pulled from the CBS's real staff
* register (`/staff`) instead of letting an officer's name be freely typed -- restricted to
* loan officers via `loanOfficersOnly` (the actual backend query param -- note this differs
* from `OrganizationService.getStaff`, which sends the mismatched `isLoanOfficer` key and so
* never actually filters). No `officeId` filter, so this is every active loan officer visible
* in the caller's own office hierarchy, same authorization the endpoint already applies
* server-side. */
listStaff(): Observable<{ id: number; displayName: string }[]> {
return this.http.get<{ id: number; displayName: string }[]>('/staff', {
params: { status: 'active', loanOfficersOnly: 'true' }
});
}
// -------------------- Uploaded documents --------------------
// Files go through Fineract's own generic document store (`m_document`, keyed by entity type and
// id) using the same multipart shape as every other upload in this app -- see
// ClientsService.uploadClientSignatureImage(). That means loan-origination attachments land in the
// same configured storage (filesystem/S3) as client and loan documents rather than in a private
// table of their own, and the standard attachment download works on them unchanged.
/** Entity type these documents are filed under in `m_document.parent_entity_type`. */
private readonly documentEntity = 'loanoriginationapplications';
private documentsUrl(applicationId: number): string {
return `/${this.documentEntity}/${applicationId}/documents`;
}
/**
* @param name Stable key identifying which checklist slot this file belongs to (see
* `LoanOriginationDocKey` usage in the Application Detail screen) -- this is what lets a file
* be matched back to its document row after a page reload.
* @param description Human-readable label shown to the officer.
*/
uploadDocument(applicationId: number, file: File, name: string, description: string): Observable<any> {
const formData = new FormData();
formData.append('file', file);
formData.append('name', name);
formData.append('description', description);
return this.http.post(this.documentsUrl(applicationId), formData);
}
listDocuments(applicationId: number): Observable<any[]> {
return this.http.get<any[]>(this.documentsUrl(applicationId));
}
downloadDocument(applicationId: number, documentId: number): Observable<Blob> {
return this.http.get(`${this.documentsUrl(applicationId)}/${documentId}/attachment`, { responseType: 'blob' });
}
deleteDocument(applicationId: number, documentId: number): Observable<any> {
return this.http.delete(`${this.documentsUrl(applicationId)}/${documentId}`);
}
}