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>
202 lines
8.6 KiB
TypeScript
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}`);
|
|
}
|
|
}
|