/** * 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 { return this.http.get(this.baseUrl); } get(applicationNo: string): Observable { return this.http.get(`${this.baseUrl}/${encodeURIComponent(applicationNo)}`); } create(application: LoanApplication): Observable { return this.http.post(this.baseUrl, application); } save(application: LoanApplication): Observable { return this.http.put( `${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 { return this.http.post(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body); } completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable { return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks }); } completeCicCheck(applicationNo: string, actionBy: string, remarks?: string): Observable { return this.stage(applicationNo, 'cic-complete', { actionBy, remarks }); } verifyFieldCheck( applicationNo: string, checkKey: string, verifiedBy: string, remarks?: string ): Observable { return this.stage(applicationNo, `field-investigation/${checkKey}/verify`, { verifiedBy, remarks }); } rejectFieldCheck( applicationNo: string, checkKey: string, verifiedBy: string, remarks: string ): Observable { return this.stage(applicationNo, `field-investigation/${checkKey}/reject`, { verifiedBy, remarks }); } completeFieldInvestigation(applicationNo: string, actionBy: string, remarks?: string): Observable { return this.stage(applicationNo, 'field-investigation/complete', { actionBy, remarks }); } clearPersonalDiscussion(applicationNo: string, actionBy: string, remarks?: string): Observable { return this.stage(applicationNo, 'personal-discussion/clear', { actionBy, remarks }); } flagDiscrepancy( applicationNo: string, actionBy: string, discrepancyType: string, remarks: string ): Observable { return this.stage(applicationNo, 'personal-discussion/flag', { actionBy, discrepancyType, remarks }); } resolveDiscrepancy(applicationNo: string, actionBy: string, remarks?: string): Observable { 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 { return this.stage(applicationNo, `approvals/${field}`, body); } markSigned(applicationNo: string, actionBy: string): Observable { return this.stage(applicationNo, 'signing', { actionBy }); } confirmChequeAch(applicationNo: string, actionBy: string): Observable { return this.stage(applicationNo, 'cheque-ach', { actionBy }); } disburse( applicationNo: string, actionBy: string, disbursementMode: string, fineractLoan?: { fineractLoanId: number; fineractLoanAccountNo: string } ): Observable { 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 { 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 { return this.http.get(this.documentsUrl(applicationId)); } downloadDocument(applicationId: number, documentId: number): Observable { return this.http.get(`${this.documentsUrl(applicationId)}/${documentId}/attachment`, { responseType: 'blob' }); } deleteDocument(applicationId: number, documentId: number): Observable { return this.http.delete(`${this.documentsUrl(applicationId)}/${documentId}`); } }