feat(loan-origination): wire pipeline and application detail to real branches and saved products

New loan application now sources branches and product type/sub-type/code
options from Product Creation's saved products (Gold, Warehouse Receipt,
Loans/EMI) and real office branches instead of hardcoded lists, with
tenure constrained to each product's min/max.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 17:59:34 +05:30
co-authored by Claude Sonnet 5
parent ec550b1798
commit 9bde697925
11 changed files with 2999 additions and 330 deletions
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -50,7 +50,8 @@ export async function createRealLoanAccount(
const locale = settings.language.code; const locale = settings.language.code;
const dateFormat = settings.dateFormat; const dateFormat = settings.dateFormat;
const today = dateUtils.formatDate(new Date(), dateFormat); // Fineract rejects a submitted-on date later than its business date, so use that (the date shown in the header), not the PC clock.
const today = dateUtils.formatDate(settings.businessDate, dateFormat);
const payload = { const payload = {
clientId: app.clientId, clientId: app.clientId,
@@ -29,6 +29,7 @@ export { BRANCHES, BranchMaster };
export type OriginationStage = export type OriginationStage =
| 'APPLICATION' | 'APPLICATION'
| 'KYC_DOCUMENTS' | 'KYC_DOCUMENTS'
| 'DEPOSIT_SELECTION'
| 'CIC_CHECK' | 'CIC_CHECK'
| 'FIELD_INVESTIGATION' | 'FIELD_INVESTIGATION'
| 'PERSONAL_DISCUSSION' | 'PERSONAL_DISCUSSION'
@@ -43,6 +44,7 @@ export type OriginationStage =
export const STAGE_ORDER: OriginationStage[] = [ export const STAGE_ORDER: OriginationStage[] = [
'APPLICATION', 'APPLICATION',
'KYC_DOCUMENTS', 'KYC_DOCUMENTS',
'DEPOSIT_SELECTION',
'CIC_CHECK', 'CIC_CHECK',
'FIELD_INVESTIGATION', 'FIELD_INVESTIGATION',
'PERSONAL_DISCUSSION', 'PERSONAL_DISCUSSION',
@@ -58,6 +60,7 @@ export const STAGE_ORDER: OriginationStage[] = [
export const STAGE_LABELS: Record<OriginationStage, string> = { export const STAGE_LABELS: Record<OriginationStage, string> = {
APPLICATION: 'Application Received', APPLICATION: 'Application Received',
KYC_DOCUMENTS: 'KYC & Document Collection', KYC_DOCUMENTS: 'KYC & Document Collection',
DEPOSIT_SELECTION: 'Deposit Selection & Eligibility',
CIC_CHECK: 'CIC / Credit Bureau Check', CIC_CHECK: 'CIC / Credit Bureau Check',
FIELD_INVESTIGATION: 'Field Investigation (FI)', FIELD_INVESTIGATION: 'Field Investigation (FI)',
PERSONAL_DISCUSSION: 'Personal Discussion with Manager', PERSONAL_DISCUSSION: 'Personal Discussion with Manager',
@@ -70,6 +73,18 @@ export const STAGE_LABELS: Record<OriginationStage, string> = {
DISBURSED: 'Disbursed' DISBURSED: 'Disbursed'
}; };
/** The stages a given product actually goes through (a loan against a deposit has no CIC and no field investigation): Deposit Selection only for a loan against a deposit, and no
* credit bureau (CIC) check for it. Everything else keeps the full 12-stage list. */
export function stagesFor(productCode: string | undefined | null): OriginationStage[] {
if (isDepositLoanCode(productCode)) {
// No credit bureau check, field investigation or security cheque / ACH (recovery is by set-off against the lien); every other stage stays.
return STAGE_ORDER.filter(
(stage) => stage !== 'CIC_CHECK' && stage !== 'FIELD_INVESTIGATION' && stage !== 'CHEQUE_ACH'
);
}
return STAGE_ORDER.filter((stage) => stage !== 'DEPOSIT_SELECTION');
}
export function stageIndex(stage: OriginationStage): number { export function stageIndex(stage: OriginationStage): number {
return STAGE_ORDER.indexOf(stage); return STAGE_ORDER.indexOf(stage);
} }
@@ -180,7 +195,54 @@ export interface StageHistoryEntry {
remarks: string; remarks: string;
} }
/** The deposit(s) pledged for a loan against FD / RD / DDS / LIC and what they allow (policy 9.5). */
export interface DepositSelection {
accountIds: number[];
accountNos: string[];
securityValue: number;
limitPct: number;
eligibleAmount: number;
depositRate: number;
spread: number;
maxTenure: number;
over85: boolean;
over85Reason: string;
thirdParty: boolean;
/** The product requires a lien on the deposit (a product can switch this off). */
lienRequired: boolean;
saved: boolean;
}
/** Lien noted on the pledged deposit(s) for this application. */
export interface LienRecord {
marked: boolean;
certificateNo: string;
markedOn: string;
amount: number;
accountNos: string[];
}
/** An approval sent for a decision (maker-checker): the user who sent it cannot be the one who decides. */
export interface ApprovalRequest {
status: 'PENDING' | 'APPROVED' | 'EXCEPTION_APPROVED' | 'REJECTED' | 'WITHDRAWN';
submittedBy: string;
submittedOn: string;
requestedDecision: 'APPROVED' | 'EXCEPTION_APPROVED';
remarks: string;
resolutionNo?: string;
resolutionDate?: string;
/** What the sender had verified at the moment of sending -- what the approver reviews. */
checklist?: { label: string; ok: boolean }[];
/** What the approver ticked as checked herself/himself before approving. */
checkerChecklist?: { label: string; ok: boolean }[];
decidedBy?: string;
decidedOn?: string;
}
export interface LoanApplication { export interface LoanApplication {
approvalRequests?: Partial<Record<string, ApprovalRequest>>;
depositSelection?: DepositSelection;
lien?: LienRecord;
/** Database row id, assigned on save. Uploaded files hang off this (documents are stored against /** 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. */ * the application's entity id), so it's absent only on an application not yet persisted. */
id?: number; id?: number;
@@ -451,7 +513,47 @@ interface OriginationProduct {
/** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws /** Minimum Share Capital the borrower must hold, as a % of the loan amount, per bank bye-laws
* (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */ * (a UCB norm -- membership/share-linkage against borrowing, distinct from margin/collateral). */
shareLinkagePercent: number; shareLinkagePercent: number;
/** Loan against a deposit: the share of the deposit value that can be lent (policy 9.5.5: 85%). */
ltvPct?: number;
/** Loan against a deposit: percentage points added to the deposit's own rate. */
spread?: number;
/** Loan against a deposit: when the lien is marked, as set on the product ("disbursement" or "sanction"). */
lienStage?: string;
lienRequired?: boolean;
/** What the eligible amount is calculated on: "balance_accrued" (balance + accrued interest) or "balance". */
eligibleBasis?: string;
/** "deposit_spread" = the deposit's own rate plus the spread; anything else uses the product's own rate. */
interestBasis?: string;
/** The product's own interest rate (used when the rate is not "deposit rate + spread"). */
baseRate?: number;
allowMultipleDeposits?: boolean;
allowThirdParty?: boolean;
/** The loan tenure may not run past the deposit's maturity. */
tenureWithinMaturity?: boolean;
certificateRequired?: boolean;
certificatePrefix?: string;
/** Terms shown on the loan plan of a deposit loan. */
penalRate?: number;
penalApplicable?: boolean;
repaymentType?: string;
interestPostingFrequency?: string;
facilityType?: string;
preclosure?: string;
renewalAllowed?: boolean;
} }
/** Short names of the loans against a deposit or policy (FD, RD, DDS, LIC): the deposit is the security, so income
* proof, bank statement, address proof and a co-borrower are optional and there is no credit bureau check. */
export const DEPOSIT_PRODUCT_CODES = [
'FD',
'RD',
'DS',
'LI'
];
export function isDepositLoanCode(productCode: string | undefined | null): boolean {
return !!productCode && DEPOSIT_PRODUCT_CODES.includes(productCode);
}
export const ORIGINATION_PRODUCTS: OriginationProduct[] = [ export const ORIGINATION_PRODUCTS: OriginationProduct[] = [
{ {
code: 'PL', code: 'PL',
@@ -586,6 +688,53 @@ interface ProductDocumentTemplate {
* encumbering that asset has to be collected before the loan can be sanctioned, on top of the * encumbering that asset has to be collected before the loan can be sanctioned, on top of the
* borrower's own identity/income proof. */ * borrower's own identity/income proof. */
export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = { export const PRODUCT_DOCUMENT_REQUIREMENTS: Record<string, ProductDocumentTemplate[]> = {
FD: [
{
key: 'fdReceipt',
label: 'FD receipt — original, discharged on the back',
hint: 'Signed by the depositor'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
RD: [
{ key: 'rdPassbook', label: 'RD passbook', hint: 'Kept by the bank' },
{
key: 'adjustDeclaration',
label: 'Declaration: bank may adjust the loan on RD maturity',
hint: 'Bank may adjust the loan from the deposit on maturity'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
DS: [
{ key: 'ddsPassbook', label: 'DDS passbook', hint: 'Kept by the bank' },
{
key: 'adjustDeclaration',
label: 'Declaration: bank may adjust the loan on DDS maturity',
hint: 'Bank may adjust the loan from the deposit on maturity'
},
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' },
{
key: 'letterOfContinuity',
label: 'Letter of continuity',
hint: 'Liability and security continue until the loan is repaid'
}
],
LI: [
{ key: 'licPolicyBond', label: 'Original LIC policy bond', hint: 'Kept by the bank' },
{ key: 'licAssignment', label: 'Assignment deed in favour of the bank', hint: 'Registered with the LIC branch' },
{ key: 'licSurrenderLetter', label: 'LIC surrender value letter', hint: 'Not older than 1 month' },
{ key: 'dpNote', label: 'Demand promissory note (DP Note)', hint: 'Promise to repay on demand' }
],
HL: [ HL: [
{ key: 'propertyTaxReceipt', label: 'Property Tax Receipt' }, { key: 'propertyTaxReceipt', label: 'Property Tax Receipt' },
{ key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' }, { key: 'propertyRegistration', label: 'Property Registration Document (Sale Deed)' },
@@ -636,15 +785,75 @@ export function buildProductDocuments(productCode: string): ProductDocumentEntry
return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false })); return (PRODUCT_DOCUMENT_REQUIREMENTS[productCode] ?? []).map((doc) => ({ ...doc, custom: false, submitted: false }));
} }
/**
* Makes a product saved in Product Creation usable by the application screens: the detail page, the eligibility
* check and the Fineract bridge all look a product up by its code in ORIGINATION_PRODUCTS, so a saved product is
* registered there under its Short Name (replacing an earlier registration of the same code).
*/
export function registerOriginationProduct(p: any): OriginationProduct {
const ext = p?.advanceExtensions ?? {};
const inMonths = !p?.repaymentFrequencyType?.value || p.repaymentFrequencyType.value === 'Months';
const rate = Number(p?.interestRatePerPeriod ?? 0);
const entry: OriginationProduct = {
code: String(p.shortName),
name: String(p.name).replace(/\s+/g, ' ').trim(),
secured: !isDepositLoanCode(String(p.shortName)) && (ext.productTypeGroup === 'advances' || !!ext.securityType),
minAmt: Number(p?.minPrincipal ?? p?.principal ?? 0),
maxAmt: Number(p?.maxPrincipal ?? p?.principal ?? 0),
minRate: Number(p?.minInterestRatePerPeriod ?? rate),
maxRate: Number(p?.maxInterestRatePerPeriod ?? rate),
minTenure: Number(inMonths ? (p?.minNumberOfRepayments ?? p?.numberOfRepayments ?? 1) : 1),
maxTenure: Number(inMonths ? (p?.maxNumberOfRepayments ?? p?.numberOfRepayments ?? 360) : 360),
incomeMultiplier: 20,
shareLinkagePercent: 5,
ltvPct: Number(ext.ltv ?? 85),
spread: Number(ext.spread ?? 0),
lienStage: String(ext.lienMarkingStage ?? 'disbursement'),
lienRequired: ext.lienRequired !== false,
eligibleBasis: String(ext.eligibleAmountBasis ?? 'balance_accrued'),
interestBasis: String(ext.interestBasis ?? 'deposit_spread'),
baseRate: rate,
allowMultipleDeposits: ext.lienAllowMultipleDeposits === true,
allowThirdParty: ext.lienAllowThirdParty === true,
tenureWithinMaturity: ext.lienTenureNotExceedMaturity !== false,
certificateRequired: ext.lienCertificateRequired !== false && ext.lienCertificateRequired !== 'NO',
certificatePrefix: ext.lienCertificatePrefix ? String(ext.lienCertificatePrefix) : undefined,
penalRate: Number(ext.penalRate ?? 0),
penalApplicable: ext.penalApplicable === true,
repaymentType: String(ext.repaymentType ?? 'bullet'),
interestPostingFrequency: String(ext.interestPostingFrequency ?? 'Monthly'),
facilityType: String(ext.facilityType ?? 'demand'),
preclosure: String(ext.preclosureAllowed ?? 'anytime'),
renewalAllowed: ext.renewalAllowed === true
};
const at = ORIGINATION_PRODUCTS.findIndex((x) => x.code === entry.code);
if (at >= 0) {
ORIGINATION_PRODUCTS[at] = entry;
} else {
ORIGINATION_PRODUCTS.push(entry);
}
return entry;
}
/** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid /** What Legal Approval actually collects proof of -- the executed agreement, the stamp duty paid
* on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern * on it, and (for a secured product) the mortgage/title paperwork -- same "Add More Doc" pattern
* as the product-specific documents above, since a specific matter can always need one more * as the product-specific documents above, since a specific matter can always need one more
* paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */ * paper (a second mortgagor's consent letter, a court NOC, etc.) than this starter list expects. */
export function buildLegalDocuments(secured: boolean): ProductDocumentEntry[] { export function buildLegalDocuments(secured: boolean, depositLoan = false): ProductDocumentEntry[] {
if (depositLoan) {
// The DP Note, letter of continuity, deposit receipt and letter of lien were collected earlier (KYC & Documents and
// Lien Marking); only what is new at the legal stage is asked here. No mortgage, title or legal opinion: the security is a deposit.
return [
{
key: 'stampDutyReceipt',
label: 'Stamp duty / revenue stamp receipt (on the DP Note and agreement)'
}
].map((doc) => ({ ...doc, custom: false, submitted: false }));
}
const templates: ProductDocumentTemplate[] = [ const templates: ProductDocumentTemplate[] = [
{ key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' }, { key: 'loanAgreement', label: 'Loan Agreement (Registered/Notarized)' },
{ key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' }, { key: 'stampDutyReceipt', label: 'Legal Stamp Paper / Stamp Duty Receipt' },
{ key: 'feeReceipt', label: 'Fee & Account Opening Receipt' } { key: 'feeReceipt', label: 'Processing Fee Receipt' }
]; ];
if (secured) { if (secured) {
templates.push( templates.push(
@@ -1839,7 +2048,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
const actionBy = const actionBy =
input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff'; input.channel === 'BC' ? input.bcAgentName : input.channel === 'SELF_APPLY' ? 'Customer (Digital)' : 'Branch Staff';
return { return {
applicationNo: `LN${input.branchCode.slice(2)}${input.productCode}${String(seq).padStart(6, '0')}`, applicationNo: `LN${input.branchCode.replace(/^(OFF|BR)/, '').padStart(2, '0')}${input.productCode}${String(seq).padStart(6, '0')}`,
applicationDate: todayDDMMYYYY(), applicationDate: todayDDMMYYYY(),
applicantName: input.applicantName, applicantName: input.applicantName,
branchCode: input.branchCode, branchCode: input.branchCode,
@@ -1932,7 +2141,7 @@ export function createApplication(input: NewApplicationInput, seq: number): Loan
legalStampDone: false, legalStampDone: false,
fileFilingDone: false, fileFilingDone: false,
agreementSigningSlot: '—', agreementSigningSlot: '—',
legalDocuments: buildLegalDocuments(input.secured), legalDocuments: buildLegalDocuments(input.secured, isDepositLoanCode(input.productCode)),
customerSigningDate: '—', customerSigningDate: '—',
chequeReceived: false, chequeReceived: false,
achSigned: false, achSigned: false,
@@ -865,6 +865,16 @@
} }
} }
.loi-legal-doc-thumb-remove.loi-bank-remove {
height: 34px;
padding: 0 14px;
border: 1px solid #fecaca;
border-radius: 999px;
background: #fef2f2;
color: #dc2626;
font-weight: 700;
}
// CIC Credit Bureau Hero Card in Stage detail // CIC Credit Bureau Hero Card in Stage detail
.loi-cic-hero-card { .loi-cic-hero-card {
display: flex; display: flex;
@@ -978,3 +988,211 @@
font-weight: 600; font-weight: 600;
} }
} }
.loi-optional-tag {
font-weight: 600;
text-transform: none;
letter-spacing: 0;
color: #0369a1;
}
.loi-deposit-note {
margin-bottom: 14px;
padding: 10px 14px;
border: 1px solid #bae6fd;
border-radius: 8px;
background: #f0f9ff;
color: #075985;
font-size: 13px;
line-height: 1.5;
}
.loi-dep-wrap {
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.loi-dep-table {
width: 100%;
border-collapse: collapse;
th {
padding: 10px 12px;
text-align: left;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
color: #64748b;
background: #f8fafc;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
td {
padding: 10px 12px;
font-size: 13.5px;
border-bottom: 1px solid #f1f5f9;
}
.r {
text-align: right;
font-variant-numeric: tabular-nums;
}
tr.loi-dep-selected td {
background: #eef2ff;
}
tr.loi-dep-disabled td {
color: #94a3b8;
}
}
.loi-dep-calc {
display: flex;
flex-direction: column;
gap: 8px;
padding: 14px 16px;
margin: 12px 0;
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
}
.loi-dep-row {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 13.5px;
color: #334155;
b {
font-variant-numeric: tabular-nums;
color: #0f172a;
}
}
.loi-dep-total {
padding-top: 10px;
border-top: 1px dashed #cbd5e1;
font-size: 15px;
b {
color: #15803d;
font-size: 16px;
}
}
.loi-dep-fields {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-bottom: 10px;
@media (width <= 700px) {
grid-template-columns: 1fr;
}
}
.loi-deposit-ok {
margin: 6px 0;
padding: 10px 14px;
border: 1px solid #bbf7d0;
border-radius: 8px;
background: #f0fdf4;
color: #15803d;
font-size: 13px;
}
.loi-lien-cert {
margin: 12px 0;
padding: 14px 18px;
border: 2px dashed #94a3b8;
border-radius: 10px;
background: #fffef7;
font-size: 13px;
line-height: 1.6;
color: #334155;
}
.pg-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.loi-approval-wait {
margin: 10px 0;
padding: 12px 14px;
border: 1px solid #fde68a;
border-radius: 8px;
background: #fffbeb;
color: #92400e;
font-size: 13px;
line-height: 1.5;
}
.loi-verified {
margin: 8px 0 12px;
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
font-size: 13px;
color: #334155;
ul {
margin: 6px 0 0;
padding-left: 0;
list-style: none;
}
li {
padding: 2px 0;
color: #15803d;
}
.loi-verified--no {
color: #b91c1c;
}
}
.loi-locked {
pointer-events: none;
opacity: 0.75;
}
.loi-checker {
margin: 8px 0 12px;
padding: 12px 14px;
border: 1px solid #c7d2fe;
border-radius: 8px;
background: #eef2ff;
font-size: 13px;
color: #1e1b4b;
}
/* A stage sent for approval: files can still be opened (View), nothing can be added, replaced or removed. */
.loi-docs-locked {
label.pg-btn,
.loi-legal-doc-thumb-remove,
.loi-doc-remove,
.loi-custom-doc-input,
.loan-action-row {
display: none !important;
}
}
.loi-view-link {
margin-left: 8px;
padding: 2px 10px;
border: 1px solid #c7d2fe;
border-radius: 999px;
background: #eef2ff;
color: #3730a3;
font-size: 12px;
font-weight: 600;
cursor: pointer;
}
@@ -9,7 +9,13 @@
import { Injectable, inject } from '@angular/core'; import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http'; import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs'; import { Observable } from 'rxjs';
import { LoanApplication } from './loan-origination-data'; import { map } from 'rxjs/operators';
import { BranchMaster, LoanApplication } from './loan-origination-data';
/** The code a branch carries on an application: the Fineract office it belongs to (office 1 is "OFF1"). */
export function officeBranchCode(officeId: number | string): string {
return `OFF${officeId}`;
}
/** /**
* Persistence for the Loan Origination Pipeline, in two deliberately different shapes. * Persistence for the Loan Origination Pipeline, in two deliberately different shapes.
@@ -29,6 +35,23 @@ export class LoanOriginationService {
private readonly baseUrl = '/loan-origination/applications'; private readonly baseUrl = '/loan-origination/applications';
/** One fixed or recurring deposit account with its balance, rate and maturity. */
getDepositAccount(kind: 'FD' | 'RD', accountId: number): Observable<any> {
return this.http.get(`/${kind === 'FD' ? 'fixeddepositaccounts' : 'recurringdepositaccounts'}/${accountId}`);
}
/** The bank's real branches: every office of the organisation, in hierarchy order. */
listBranches(): Observable<BranchMaster[]> {
return this.http.get<{ id: number; name: string; hierarchy?: string }[]>('/offices').pipe(
map((offices) =>
(offices ?? [])
.slice()
.sort((a, b) => (a.hierarchy ?? '').localeCompare(b.hierarchy ?? '') || a.id - b.id)
.map((office) => ({ code: officeBranchCode(office.id), name: office.name }))
)
);
}
list(): Observable<LoanApplication[]> { list(): Observable<LoanApplication[]> {
return this.http.get<LoanApplication[]>(this.baseUrl); return this.http.get<LoanApplication[]>(this.baseUrl);
} }
@@ -67,6 +90,10 @@ export class LoanOriginationService {
return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body); return this.http.post<LoanApplication>(`${this.baseUrl}/${encodeURIComponent(applicationNo)}/${path}`, body);
} }
completeDepositSelection(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'deposit-selection-complete', { actionBy, remarks });
}
completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> { completeKyc(applicationNo: string, actionBy: string, remarks?: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks }); return this.stage(applicationNo, 'kyc-complete', { actionBy, remarks });
} }
@@ -124,11 +151,31 @@ export class LoanOriginationService {
remarks?: string; remarks?: string;
resolutionNo?: string; resolutionNo?: string;
resolutionDate?: string; resolutionDate?: string;
checkerChecklist?: { label: string; ok: boolean }[];
} }
): Observable<LoanApplication> { ): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}`, body); return this.stage(applicationNo, `approvals/${field}`, body);
} }
submitApproval(
applicationNo: string,
field: string,
body: {
decision: 'APPROVED' | 'EXCEPTION_APPROVED';
approverRole: string;
remarks?: string;
resolutionNo?: string;
resolutionDate?: string;
checklist?: { label: string; ok: boolean }[];
}
): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}/submit`, body);
}
withdrawApproval(applicationNo: string, field: string): Observable<LoanApplication> {
return this.stage(applicationNo, `approvals/${field}/withdraw`, {});
}
markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> { markSigned(applicationNo: string, actionBy: string): Observable<LoanApplication> {
return this.stage(applicationNo, 'signing', { actionBy }); return this.stage(applicationNo, 'signing', { actionBy });
} }
@@ -171,6 +171,51 @@
</div> </div>
</div> </div>
@if (awaitingApproval.length > 0) {
<div class="pg-card">
<div class="pos-section-label">Awaiting Approval ({{ awaitingApproval.length }})</div>
<div class="pg-table-scroll">
<table class="pg-table">
<thead>
<tr>
<th>Application No</th>
<th>Applicant</th>
<th>Stage</th>
<th>Sent by</th>
<th>Sent on</th>
<th>For you</th>
</tr>
</thead>
<tbody>
@for (row of awaitingApproval; track row.app.applicationNo + row.stage) {
<tr class="cctr-branch-row" (click)="openApplication(row.app)">
<td>
<span class="pg-chip">{{ row.app.applicationNo }}</span>
</td>
<td>{{ row.app.applicantName }}</td>
<td>
{{ row.stage }}
@if (row.exception) {
(exception)
}
</td>
<td>{{ row.sentBy }}</td>
<td>{{ row.sentOn }}</td>
<td>
@if (row.mine) {
<span class="loan-badge loan-badge--hold">Sent by you — waiting for another user</span>
} @else {
<span class="loan-badge loan-badge--missing">Needs your decision</span>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
}
<div class="pg-card"> <div class="pg-card">
<div class="pos-section-label">Applications ({{ filteredApplications.length }})</div> <div class="pos-section-label">Applications ({{ filteredApplications.length }})</div>
<div class="pg-table-scroll"> <div class="pg-table-scroll">
@@ -304,18 +349,37 @@
</select> </select>
</label> </label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Product</span> <span>* Product Type</span>
<select [(ngModel)]="newApp.productCode" (ngModelChange)="onProductChange()"> <select [(ngModel)]="newApp.productGroup" (ngModelChange)="onProductGroupChange()">
@for (p of products; track p.code) { <option value="">Select product type</option>
<option [value]="p.code">{{ p.name }}</option> @for (g of productGroups; track g.id) {
<option [value]="g.id">{{ g.label }}</option>
} }
</select> </select>
</label> </label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>* Product Type</span> <span>* Sub Type</span>
<select [(ngModel)]="newApp.productType"> <select
@for (t of newAppProductTypeOptions; track t) { [(ngModel)]="newApp.productSubType"
<option [value]="t">{{ productTypeLabels[t] }}</option> (ngModelChange)="onSubTypeChange()"
[disabled]="!newApp.productGroup"
>
<option value="">Select sub type</option>
@for (t of subTypeOptions; track t.id) {
<option [value]="t.id">{{ t.label }}</option>
}
</select>
</label>
<label class="inv-form-field">
<span>* Product</span>
<select
[(ngModel)]="newApp.productCode"
(ngModelChange)="onProductChange()"
[disabled]="!newApp.productSubType"
>
<option value="">{{ loadingProducts ? 'Loading products…' : 'Select product' }}</option>
@for (p of productOptions; track p.id) {
<option [value]="p.shortName">{{ p.name }}</option>
} }
</select> </select>
</label> </label>
@@ -325,7 +389,12 @@
</label> </label>
<label class="inv-form-field"> <label class="inv-form-field">
<span>Tenure (Months)</span> <span>Tenure (Months)</span>
<input type="number" [(ngModel)]="newApp.tenureMonths" placeholder="e.g. 36" /> <select [(ngModel)]="newApp.tenureMonths" [disabled]="!selectedProduct">
<option [ngValue]="null">{{ selectedProduct ? 'Select tenure' : 'Select a product first' }}</option>
@for (m of tenureOptions; track m) {
<option [ngValue]="m">{{ m }} months</option>
}
</select>
</label> </label>
</div> </div>
@@ -13,9 +13,10 @@ import { ActivatedRoute, Router, RouterModule } from '@angular/router';
import { AuthenticationService } from '../../core/authentication/authentication.service'; import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ClientsService } from '../../clients/clients.service'; import { ClientsService } from '../../clients/clients.service';
import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export'; import { ExportSheetSpec, exportWorkbook } from '../../cashier/cashier-export';
import { LoanOriginationService } from '../loan-origination.service'; import { LoanOriginationService, officeBranchCode } from '../loan-origination.service';
import { LoanProductCatalogService } from '../../loans/services/loan-product-catalog.service';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS } from '../../products/loan-products/loan-product-types';
import { import {
BRANCHES,
BranchMaster, BranchMaster,
branchTargetAmount, branchTargetAmount,
Channel, Channel,
@@ -25,10 +26,10 @@ import {
generateSeedApplications, generateSeedApplications,
LoanApplication, LoanApplication,
ORIGINATION_PRODUCTS, ORIGINATION_PRODUCTS,
registerOriginationProduct,
OriginationStage, OriginationStage,
OverallStatus, OverallStatus,
PRODUCT_TARGET_PCT, PRODUCT_TARGET_PCT,
PRODUCT_TYPE_LABELS,
ProductType, ProductType,
SHARE_LINKAGE_SLABS, SHARE_LINKAGE_SLABS,
STAGE_LABELS, STAGE_LABELS,
@@ -36,9 +37,7 @@ import {
STATUS_LABELS, STATUS_LABELS,
TARGET_STATUS_LABELS, TARGET_STATUS_LABELS,
TargetStatus, TargetStatus,
allowedProductTypes,
createApplication, createApplication,
defaultProductType,
progressPct, progressPct,
stageBadgeClass, stageBadgeClass,
targetStatus, targetStatus,
@@ -124,7 +123,12 @@ export class LoanPipelineComponent implements OnInit {
readonly stageLabels = STAGE_LABELS; readonly stageLabels = STAGE_LABELS;
readonly statusLabels = STATUS_LABELS; readonly statusLabels = STATUS_LABELS;
readonly channelLabels = CHANNEL_LABELS; readonly channelLabels = CHANNEL_LABELS;
readonly branches = BRANCHES; /** The bank's offices (real). Until they load, the logged-in user's own office is the only choice. */
branches: BranchMaster[] = [];
/** Active products saved in Product Creation -- the only products an application can be taken for. */
dbProducts: any[] = [];
loadingProducts = true;
readonly productGroups = PRODUCT_TYPE_GROUPS;
readonly products = ORIGINATION_PRODUCTS; readonly products = ORIGINATION_PRODUCTS;
readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS; readonly shareLinkageSlabs = SHARE_LINKAGE_SLABS;
asOnDate = todayDDMMYYYY(); asOnDate = todayDDMMYYYY();
@@ -152,26 +156,63 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW' as CustomerType, customerType: 'NEW' as CustomerType,
applicantName: '', applicantName: '',
clientId: null as number | null, clientId: null as number | null,
branchCode: this.branches[0].code, branchCode: '',
productCode: this.products[0].code, productGroup: '',
productType: defaultProductType(this.products[0].code), productSubType: '',
productCode: '',
productType: 'TERM_LOAN' as ProductType,
requestedAmount: null as number | null, requestedAmount: null as number | null,
tenureMonths: null as number | null tenureMonths: null as number | null
}; };
readonly productTypeLabels = PRODUCT_TYPE_LABELS; /** Sub types of the chosen Product Type that actually have an active product saved. */
get subTypeOptions() {
/** Which Product Types the currently-selected product can be sanctioned as -- Cash Credit/KCC const group = this.newApp.productGroup;
* are Overdraft-only, Gold Loan offers both, everything else is Term Loan-only. */ return (PRODUCT_SUB_TYPES[group] ?? []).filter((t) =>
get newAppProductTypeOptions(): ProductType[] { this.dbProducts.some(
return allowedProductTypes(this.newApp.productCode); (p) => p.advanceExtensions?.productTypeGroup === group && p.advanceExtensions?.productTypeSubGroup === t.id
)
);
} }
/** Switching Product can change which Product Types are even valid -- reset to that product's /** The products saved in the database for the chosen Product Type and Sub Type. */
* default rather than leaving a stale selection (e.g. "Overdraft" left over from Gold Loan) get productOptions(): any[] {
* on a product that doesn't offer it. */ return this.dbProducts.filter(
(p) =>
p.advanceExtensions?.productTypeGroup === this.newApp.productGroup &&
p.advanceExtensions?.productTypeSubGroup === this.newApp.productSubType
);
}
/** Only the tenures the chosen product allows (its min to max, in months). */
get tenureOptions(): number[] {
const p = this.selectedProduct;
if (!p) {
return [];
}
return Array.from({ length: Math.max(0, p.maxTenure - p.minTenure + 1) }, (_, i) => p.minTenure + i);
}
get selectedProduct() {
return this.products.find((p) => p.code === this.newApp.productCode) || null;
}
onProductGroupChange(): void {
this.newApp.productSubType = '';
this.newApp.productCode = '';
}
onSubTypeChange(): void {
const options = this.productOptions;
this.newApp.productCode = options.length === 1 ? options[0].shortName : '';
this.onProductChange();
}
/** Advances have no EMI (revolving limit); Loans are EMI term loans. */
onProductChange(): void { onProductChange(): void {
this.newApp.productType = defaultProductType(this.newApp.productCode); this.newApp.productType = this.newApp.productGroup === 'advances' ? 'OVERDRAFT' : 'TERM_LOAN';
const product = this.selectedProduct;
this.newApp.tenureMonths = product ? product.minTenure : null;
} }
/** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */ /** Existing-customer search (real Fineract clients, unlike the rest of this mock pipeline). */
@@ -189,6 +230,7 @@ export class LoanPipelineComponent implements OnInit {
private readonly auth = inject(AuthenticationService); private readonly auth = inject(AuthenticationService);
private readonly clientsService = inject(ClientsService); private readonly clientsService = inject(ClientsService);
private readonly originationService = inject(LoanOriginationService); private readonly originationService = inject(LoanOriginationService);
private readonly catalog = inject(LoanProductCatalogService);
/** The live book, loaded from the database. Every count, report and filter on this screen derives /** The live book, loaded from the database. Every count, report and filter on this screen derives
* from this one array, so the list can never disagree with what an application actually says. */ * from this one array, so the list can never disagree with what an application actually says. */
@@ -221,17 +263,27 @@ export class LoanPipelineComponent implements OnInit {
const creds = this.auth.getCredentials(); const creds = this.auth.getCredentials();
this.isHeadOffice = this.auth.isHeadOfficeUser(); this.isHeadOffice = this.auth.isHeadOfficeUser();
this.currentOfficeName = creds?.officeName || 'Branch Office'; this.currentOfficeName = creds?.officeName || 'Branch Office';
if (this.isHeadOffice) { this.ownBranchCode = creds?.officeId ? officeBranchCode(creds.officeId) : null;
this.branchFilter = ''; // The logged-in user's own office is the default branch. A branch-level login can only originate for its own branch:
} else { // the select is disabled and `saveNewApplication` forces the code again, so it is locked here and not just hidden.
this.ownBranchCode = this.branches = this.ownBranchCode ? [{ code: this.ownBranchCode, name: this.currentOfficeName }] : [];
this.branches.find((b: BranchMaster) => this.currentOfficeName.toLowerCase().includes(b.name.toLowerCase())) this.branchFilter = this.isHeadOffice ? '' : this.ownBranchCode || '';
?.code || null; this.newApp.branchCode = this.ownBranchCode || '';
this.branchFilter = this.ownBranchCode || this.branches[0].code; this.catalog.activeProducts().subscribe((products) => {
// A branch-level login can only originate applications for its own branch — locked here, this.dbProducts = products.filter((p) => p.shortName && p.advanceExtensions?.productTypeGroup);
// not just hidden, since the New Application form posts whatever branchCode is bound. this.dbProducts.forEach((p) => registerOriginationProduct(p));
this.newApp.branchCode = this.branchFilter; this.loadingProducts = false;
} });
this.originationService.listBranches().subscribe({
next: (offices) => {
if (offices.length > 0) {
this.branches = offices;
}
},
error: () => {
/* keep the user's own office as the only choice */
}
});
this.loadApplications(); this.loadApplications();
} }
@@ -310,6 +362,42 @@ export class LoanPipelineComponent implements OnInit {
return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications; return this.branchFilter ? this.applications.filter((a) => a.branchCode === this.branchFilter) : this.applications;
} }
private get currentUserName(): string {
return (this.auth.getCredentials()?.username ?? '').toLowerCase();
}
/** Applications with an approval sent and waiting for a decision (maker-checker), newest first. */
get awaitingApproval() {
const rows: {
app: LoanApplication;
stage: string;
sentBy: string;
sentOn: string;
mine: boolean;
exception: boolean;
}[] = [];
this.applications.forEach((app) => {
Object.entries(app.approvalRequests ?? {}).forEach(
([
,
request
]) => {
if (request && request.status === 'PENDING') {
rows.push({
app,
stage: STAGE_LABELS[app.currentStage],
sentBy: request.submittedBy,
sentOn: request.submittedOn,
mine: (request.submittedBy ?? '').toLowerCase() === this.currentUserName,
exception: request.requestedDecision === 'EXCEPTION_APPROVED'
});
}
}
);
});
return rows;
}
get filteredApplications(): LoanApplication[] { get filteredApplications(): LoanApplication[] {
return this.scopedApplications.filter((a) => { return this.scopedApplications.filter((a) => {
if (this.channelFilter && a.channel !== this.channelFilter) { if (this.channelFilter && a.channel !== this.channelFilter) {
@@ -543,7 +631,7 @@ export class LoanPipelineComponent implements OnInit {
// -------------------- New Application intake -------------------- // -------------------- New Application intake --------------------
productOf(code: string) { productOf(code: string) {
return this.products.find((p) => p.code === code) || this.products[0]; return this.products.find((p) => p.code === code);
} }
/** Switching Customer Type away from "Existing" drops any client picked under the old mode -- /** Switching Customer Type away from "Existing" drops any client picked under the old mode --
@@ -608,8 +696,27 @@ export class LoanPipelineComponent implements OnInit {
if (!this.isHeadOffice && this.ownBranchCode) { if (!this.isHeadOffice && this.ownBranchCode) {
this.newApp.branchCode = this.ownBranchCode; this.newApp.branchCode = this.ownBranchCode;
} }
const branch = this.branches.find((b) => b.code === this.newApp.branchCode) || this.branches[0]; const branch = this.branches.find((b) => b.code === this.newApp.branchCode) ||
this.branches[0] || { code: this.newApp.branchCode, name: this.currentOfficeName };
const product = this.productOf(this.newApp.productCode); const product = this.productOf(this.newApp.productCode);
if (!product) {
this.toast('Select the Product Type, Sub Type and Product.');
return;
}
if (
this.newApp.requestedAmount &&
(this.newApp.requestedAmount < product.minAmt || this.newApp.requestedAmount > product.maxAmt)
) {
this.toast(
`Amount must be between ₹${product.minAmt.toLocaleString('en-IN')} and ₹${product.maxAmt.toLocaleString('en-IN')} for ${product.name}.`
);
return;
}
const tenure = this.newApp.tenureMonths || product.minTenure;
if (tenure < product.minTenure || tenure > product.maxTenure) {
this.toast(`Tenure must be between ${product.minTenure} and ${product.maxTenure} months for ${product.name}.`);
return;
}
const seq = this.applications.length + 20000; const seq = this.applications.length + 20000;
const app = createApplication( const app = createApplication(
{ {
@@ -644,9 +751,11 @@ export class LoanPipelineComponent implements OnInit {
customerType: 'NEW', customerType: 'NEW',
applicantName: '', applicantName: '',
clientId: null, clientId: null,
branchCode: this.isHeadOffice ? this.branches[0].code : this.ownBranchCode || this.branches[0].code, branchCode: this.ownBranchCode || this.branches[0]?.code || '',
productCode: this.products[0].code, productGroup: '',
productType: defaultProductType(this.products[0].code), productSubType: '',
productCode: '',
productType: 'TERM_LOAN',
requestedAmount: null, requestedAmount: null,
tenureMonths: null tenureMonths: null
}; };
@@ -90,7 +90,7 @@
@if (tenureBand; as band) { @if (tenureBand; as band) {
<mat-hint> <mat-hint>
{{ band.label }} policy: max {{ band.max }} months {{ band.label }} policy: max {{ band.max }} months
@if (band.label === 'FD') { @if (band.depositBacked) {
(also capped at deposit maturity) (also capped at deposit maturity)
} }
</mat-hint> </mat-hint>
@@ -111,14 +111,27 @@ export class LoanProductAdvanceLimitTenureStepComponent {
} }
/** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */ /** Bank-policy tenure band per sub type: FD 12 to 15 months, a bullet Gold loan 1 to 12. Others stay open. */
private readonly tenureBands: Record<string, { label: string; min: number; max: number; defaultTenure: number }> = { private readonly tenureBands: Record<
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 }, string,
{ label: string; min: number; max: number; defaultTenure: number; depositBacked?: boolean }
> = {
// A loan against a deposit or policy is a short-term advance: 12 to 15 months for all four, and never past maturity.
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
rd: { label: 'RD', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
dds: { label: 'DDS', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
lic: { label: 'LIC', min: 12, max: 15, defaultTenure: 12, depositBacked: true },
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 }, gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 } warehouse: { label: 'Warehouse', min: 1, max: 12, defaultTenure: 6 }
}; };
/** The policy band in force for the chosen sub type, or null when the sub type is open. */ /** The policy band in force for the chosen sub type, or null when the sub type is open. */
get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null { get tenureBand(): {
label: string;
min: number;
max: number;
defaultTenure: number;
depositBacked?: boolean;
} | null {
return this.tenureBands[this._productSubType] ?? null; return this.tenureBands[this._productSubType] ?? null;
} }
@@ -131,7 +144,7 @@ export class LoanProductAdvanceLimitTenureStepComponent {
} }
/** /**
* FD (12–15 months) and Gold (1–12 months) are bank-policy capped; every other sub type stays open (min 1). * FD, RD, DDS and LIC (12–15 months), Gold and Warehouse (1–12 months) are bank-policy capped; every other sub type stays open (min 1).
* Called whenever the sub type changes so the validators — and thus the wizard's validity gate — * Called whenever the sub type changes so the validators — and thus the wizard's validity gate —
* match the selected product. * match the selected product.
*/ */
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */ /* tslint:disable */
export default { export default {
'mifos_x': { 'mifos_x': {
'version': '261013', 'version': '261022',
'hash': 'd88f2a6eb' 'hash': 'ec550b179'
}, },
'allow_switching_backend_instance': true 'allow_switching_backend_instance': true
}; };