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>
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* 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 { firstValueFrom } from 'rxjs';
|
||||
import { Dates } from '../core/utils/dates';
|
||||
import { LoansService } from '../loans/loans.service';
|
||||
import { ProductsService } from '../products/products.service';
|
||||
import { SettingsService } from '../settings/settings.service';
|
||||
import { LoanApplication, ORIGINATION_PRODUCTS } from './loan-origination-data';
|
||||
|
||||
export interface RealLoanCreationResult {
|
||||
loanId: number;
|
||||
loanAccountNo: string;
|
||||
}
|
||||
|
||||
const cleanName = (name: string): string => name.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
/** Creates, approves and disburses a real Fineract loan account for an Existing-Customer
|
||||
* application at the moment an officer disburses it -- see loan-fineract-bridge plan for why this
|
||||
* only fires at final disbursement rather than at wizard-submit or Committee sanction. Every
|
||||
* mandatory Fineract field this mock pipeline never collected (loan purpose, interest type,
|
||||
* amortization type, interest calculation period, repayment strategy) is pulled from the real
|
||||
* loan product's own template rather than guessed, so it's always valid for whatever that
|
||||
* Fineract instance actually has configured. */
|
||||
export async function createRealLoanAccount(
|
||||
app: LoanApplication,
|
||||
loansService: LoansService,
|
||||
productsService: ProductsService,
|
||||
dateUtils: Dates,
|
||||
settings: SettingsService
|
||||
): Promise<RealLoanCreationResult> {
|
||||
const mockProduct = ORIGINATION_PRODUCTS.find((p) => p.code === app.productCode);
|
||||
const wantedName = cleanName(mockProduct?.name || app.productName);
|
||||
|
||||
const products: any[] = (await firstValueFrom(productsService.getLoanProducts())) || [];
|
||||
const matchedProduct = products.find((p) => cleanName(p.name) === wantedName);
|
||||
if (!matchedProduct) {
|
||||
throw new Error(
|
||||
`No Fineract loan product named "${mockProduct?.name || app.productName}" was found -- ask an admin to configure one before disbursing.`
|
||||
);
|
||||
}
|
||||
|
||||
const template: any = await firstValueFrom(productsService.getLoanProduct(String(matchedProduct.id), true));
|
||||
const repaymentEvery: number = template.repaymentEvery || 1;
|
||||
|
||||
const locale = settings.language.code;
|
||||
const dateFormat = settings.dateFormat;
|
||||
const today = dateUtils.formatDate(new Date(), dateFormat);
|
||||
|
||||
const payload = {
|
||||
clientId: app.clientId,
|
||||
productId: matchedProduct.id,
|
||||
loanType: 'individual',
|
||||
principal: app.requestedAmount,
|
||||
loanTermFrequency: app.tenureMonths,
|
||||
loanTermFrequencyType: 2, // Months
|
||||
numberOfRepayments: Math.max(1, Math.round(app.tenureMonths / repaymentEvery)),
|
||||
repaymentEvery,
|
||||
repaymentFrequencyType: template.repaymentFrequencyType?.id,
|
||||
interestRatePerPeriod: app.interestRate,
|
||||
interestType: template.interestType?.id,
|
||||
interestCalculationPeriodType: template.interestCalculationPeriodType?.id,
|
||||
amortizationType: template.amortizationType?.id,
|
||||
transactionProcessingStrategyCode: template.transactionProcessingStrategyCode,
|
||||
submittedOnDate: today,
|
||||
expectedDisbursementDate: today,
|
||||
dateFormat,
|
||||
locale
|
||||
};
|
||||
|
||||
const created: any = await firstValueFrom(loansService.createLoansAccount(payload));
|
||||
const loanId: number = created.loanId ?? created.resourceId;
|
||||
|
||||
await firstValueFrom(
|
||||
loansService.loanActionButtons(loanId, 'approve', {
|
||||
approvedOnDate: today,
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
);
|
||||
|
||||
await firstValueFrom(
|
||||
loansService.loanActionButtons(loanId, 'disburse', {
|
||||
actualDisbursementDate: today,
|
||||
transactionAmount: app.requestedAmount,
|
||||
dateFormat,
|
||||
locale
|
||||
})
|
||||
);
|
||||
|
||||
const account: any = await firstValueFrom(loansService.getLoanAccountResource(String(loanId), ''));
|
||||
return {
|
||||
loanId,
|
||||
loanAccountNo: account?.accountNo || String(loanId)
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user