Compare commits

...
Author SHA1 Message Date
sonaliandClaude Sonnet 5.5 ec550b1798 feat(loan-products): Gold, Warehouse Receipt and Loans (EMI) product creation, view and edit
Advances A/C
- Loan against Gold: Security & Gold tab (valuation basis, appraiser, custody, revaluation on NPA,
  partial release, disbursement mode and cash limit, LTV alert, auction notice and reserve price),
  Maximum LTV cap, 1-12 month tenure band, lien/deposit fields hidden and not sent.
- Warehouse Receipt: Security & Warehouse tab (receipt type, commodity groups, valuation price source,
  inspection, insurance, disposal rules), 1-12 month tenure band, Commodity collateral type.
- Category follows the sub type; Priority Sector and CIBIL follow the category (officer's choice kept).
- Edit keeps the saved charges (filling the currency used to empty the list).
- View page shows the saved Advances settings; Review and the maker-checker diff carry the new fields.

Loans A/C (EMI)
- Edit opens in the same wizard and fills every tab from the saved record; View shows the saved settings
  (eligibility, security and guarantors, disbursement, repayment, interest, prepayment).
- Security conditions follow the sub type (property, vehicle, gold, unsecured); the sub type is now passed
  to the Security step, so its per-type defaults apply.
- Tenure band and age limits per sub type from the bank's loan policy (max 120 months); member,
  guarantor-member and income-multiple eligibility fields.
- Repayment & EMI is common to all EMI loans; cheques (PDC) are the default mode, per the policy.
- Floating-rate foreclosure for an individual is locked at nil (RBI); the duplicate "release on closure" and the
  static collateral rules table are removed.
- Term loan no longer sends the deposit-cover rule or "out of order" (CC / OD only).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-19 10:54:36 +05:30
sonali 47785f59bc Merge branch 'dev-latest' of https://git.lincpay.tech/CBSNexora/cbsnexora into feature/advanceLoanProduct 2026-10-16 10:34:42 +05:30
sonali 127c2a7d01 implemented the Edit product functionality for the RD,FD,DDS,LIC 2026-10-16 10:32:18 +05:30
60 changed files with 4676 additions and 752 deletions
@@ -7,6 +7,27 @@
-->
<form [formGroup]="loansAccountDetailsForm">
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
<mat-label>Category</mat-label>
<mat-select [value]="categoryFilter" (selectionChange)="categoryFilter = $event.value; onCategoryChange()">
<mat-option value="">All categories</mat-option>
@for (category of categories; track category.id) {
<mat-option [value]="category.id">{{ category.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>Product type</mat-label>
<mat-select [value]="typeFilter" (selectionChange)="typeFilter = $event.value; applyProductFilters()">
<mat-option value="">All types</mat-option>
@for (type of typeOptions; track type.id) {
<mat-option [value]="type.id">{{ type.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<div class="layout-row-wrap gap-2px responsive-column">
<mat-form-field class="flex-fill">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
@@ -35,6 +56,8 @@
</mat-form-field>
</div>
<mifosx-loan-product-template-card [product]="selectedProduct"></mifosx-loan-product-template-card>
@if (loanProductSelected) {
<div class="layout-row-wrap layout-lt-md-column gap-2percent">
<mat-form-field class="flex-48">
@@ -96,6 +96,15 @@
>
Edit · Submit
</button>
<button
type="button"
class="lirr__icon-btn lirr__icon-btn--danger"
title="Delete draft"
aria-label="Delete draft"
(click)="deleteDraft(r, $event)"
>
<i class="fas fa-trash"></i>
</button>
</ng-container>
<ng-container *ngSwitchCase="'Active'">
<button
@@ -522,3 +522,31 @@
color: #3730a3;
}
}
/* Icon-only action on a row (delete draft). */
.lirr__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #fff;
color: #64748b;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--danger {
color: #dc2626;
border-color: #fecaca;
&:hover {
background: #fef2f2;
}
}
}
@@ -10,6 +10,8 @@ import { Component, OnInit, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
import { Router } from '@angular/router';
import { MatSnackBar } from '@angular/material/snack-bar';
import { MatDialog } from '@angular/material/dialog';
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { MatExpansionModule } from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RateRegisterService } from './rate-register.service';
@@ -33,6 +35,7 @@ export class LoanInterestRateRegisterComponent implements OnInit {
private readonly service = inject(RateRegisterService);
private readonly router = inject(Router);
private readonly snack = inject(MatSnackBar);
private readonly dialog = inject(MatDialog);
registers: RateRegister[] = [];
active?: RateRegister;
@@ -71,6 +74,22 @@ export class LoanInterestRateRegisterComponent implements OnInit {
}
}
/** Deletes a draft (a version that was never submitted) after a confirmation. */
deleteDraft(register: RateRegister, event: Event): void {
event.stopPropagation();
const label = register.name || `draft from ${this.formatShort(register.effectiveFrom)}`;
this.dialog
.open(DeleteDialogComponent, { data: { deleteContext: label } })
.afterClosed()
.subscribe((response: any) => {
if (response?.delete) {
this.service.deleteDraft(register.id);
this.snack.open('Draft deleted', 'OK', { duration: 3000 });
this.reload();
}
});
}
/** Checker approves the pending register. The previously-Active one is deactivated. */
approve(register: RateRegister, event: Event): void {
event.stopPropagation();
@@ -52,6 +52,13 @@ export class RateRegisterService {
}
}
/** Throws a draft away. Only a local, never-submitted draft can go; server versions are never touched. */
deleteDraft(id: number): void {
this.localDrafts = this.localDrafts.filter((d) => !(d.id === id && d.status === 'Draft'));
this.cache = this.cache.filter((d) => !(d.id === id && d.status === 'Draft'));
this.saveDrafts();
}
/** Persist the current drafts (call after any edit the user should not lose). */
saveDrafts(): void {
try {
@@ -9,7 +9,9 @@
<div class="lp-container">
<div class="lp-header">
<i class="fas fa-hand-holding-usd lp-header__icon"></i>
<h2 class="lp-header__title">{{ 'labels.heading.Create Loan Product' | translate }}</h2>
<h2 class="lp-header__title">
{{ isEdit ? 'Edit Loan Product' : ('labels.heading.Create Loan Product' | translate) }}
</h2>
</div>
<mat-tab-group
@@ -30,7 +32,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-coins lp-tab-icon"></i>
{{ 'labels.inputs.CURRENCY' | translate }}
@@ -43,10 +45,16 @@
</mat-tab>
@if (isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; LIEN
{{
advanceSubType === 'gold'
? 'SECURITY & GOLD'
: advanceSubType === 'warehouse'
? 'SECURITY & WAREHOUSE'
: 'SECURITY & LIEN'
}}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-security-lien-step
@@ -57,7 +65,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
@@ -69,7 +77,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -82,19 +90,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
@@ -105,7 +101,7 @@
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
@@ -118,27 +114,31 @@
}
@if (isLoanAccount) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-shield-alt lp-tab-icon"></i>
SECURITY &amp; COLLATERAL
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-security-step></mifosx-loan-product-loan-security-step>
<mifosx-loan-product-loan-security-step
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-loan-security-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
AMOUNT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-loan-amount-tenure-step></mifosx-loan-product-loan-amount-tenure-step>
<mifosx-loan-product-loan-amount-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-loan-amount-tenure-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-calendar-alt lp-tab-icon"></i>
REPAYMENT &amp; EMI
@@ -148,7 +148,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
@@ -161,19 +161,7 @@
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-flag-checkered lp-tab-icon"></i>
PREPAYMENT &amp; CLOSURE
@@ -184,12 +172,27 @@
</mat-tab>
}
<!-- Common to both Advances A/C and Loans A/C (and visible even before a product type is chosen). -->
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<!--
OLD LOANS A/C PATH — commented out for now (replaced by the Loans A/C steps above).
Settings, Interest Refund, Payment Allocation and Terms tabs are kept here so they can be restored.
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
@@ -206,7 +209,7 @@
}
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-undo lp-tab-icon"></i>
{{ 'labels.inputs.INTEREST REFUND' | translate }}
@@ -219,7 +222,7 @@
</div>
</mat-tab>
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-exchange-alt lp-tab-icon"></i>
{{ 'labels.inputs.PAYMENT ALLOCATION' | translate }}
@@ -240,7 +243,7 @@
}
@if (!isAdvanceProduct) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
{{ 'labels.inputs.TERMS' | translate }}
@@ -254,7 +257,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-receipt lp-tab-icon"></i>
{{ 'labels.inputs.CHARGES' | translate }}
@@ -270,7 +273,7 @@
<!-- OLD LOANS A/C PATH (Deferred Income Recognition) — commented out for now.
@if (isAdvancedPaymentStrategy) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-clock lp-tab-icon"></i>
{{ 'labels.inputs.DEFERRED INCOME RECOGNITION' | translate }}
@@ -291,7 +294,7 @@
}
-->
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-book lp-tab-icon"></i>
{{ 'labels.inputs.ACCOUNTING' | translate }}
@@ -302,12 +305,15 @@
[accountingRuleData]="accountingRuleData"
[loanProductFormValid]="loanProductFormValid"
[deferredIncomeRecognition]="deferredIncomeRecognition"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')?.value"
[productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')?.value"
[productGlAccountId]="productGlAccount"
></mifosx-loan-product-accounting-step>
</div>
</mat-tab>
@if (loanProductFormValid) {
<mat-tab>
<mat-tab [disabled]="!hasProductType">
<ng-template mat-tab-label>
<i class="fas fa-eye lp-tab-icon"></i>
{{ 'labels.inputs.PREVIEW' | translate }}
@@ -316,7 +322,7 @@
<mifosx-loan-product-preview-step
[loanProductsTemplate]="loanProductsTemplate"
[accountingRuleData]="accountingRuleData"
[loanProduct]="loanProduct"
[loanProduct]="previewLoanProduct"
(submitEvent)="submit()"
></mifosx-loan-product-preview-step>
</div>
@@ -324,6 +330,25 @@
}
</mat-tab-group>
@if (hasProductType && !loanProductFormValid) {
<div class="lp-incomplete">
<i class="fas fa-circle-info"></i>
<div>
<strong>The Review tab appears once every tab is complete.</strong>
Still to finish:
@for (item of incompleteTabs; track item.tab; let last = $last) {
<b>{{ item.tab }}</b>
@if (item.fields.length > 0) {
({{ item.fields.join(', ') }})
}
@if (!last) {
,
}
}
</div>
</div>
}
<div class="lp-action-bar">
<button mat-raised-button [routerLink]="['../']" class="lp-action-bar__cancel">
{{ 'labels.buttons.Cancel' | translate }}
@@ -336,7 +361,12 @@
</button>
}
@if (selectedTabIndex < totalTabs - 1) {
<button mat-raised-button color="primary" (click)="selectedTabIndex = selectedTabIndex + 1">
<button
mat-raised-button
color="primary"
[disabled]="!hasProductType"
(click)="selectedTabIndex = selectedTabIndex + 1"
>
{{ 'labels.buttons.Next' | translate }}
<i class="fas fa-arrow-right" style="margin-left: 8px"></i>
</button>
@@ -344,7 +374,7 @@
@if (loanProductFormValid && selectedTabIndex === totalTabs - 1) {
<button mat-raised-button color="primary" (click)="submit()">
<i class="fas fa-check" style="margin-right: 8px"></i>
{{ 'labels.buttons.Submit' | translate }}
{{ isEdit ? 'Save changes' : ('labels.buttons.Submit' | translate) }}
</button>
}
</div>
@@ -90,3 +90,27 @@ $primary: #1353a0;
gap: 12px;
}
}
/* Tells the user which tabs still block the Review tab. */
.lp-incomplete {
display: flex;
align-items: flex-start;
gap: 10px;
margin: 16px 0 0;
padding: 10px 14px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
line-height: 1.5;
i {
margin-top: 3px;
color: #d97706;
}
b {
color: #92400e;
}
}
@@ -7,7 +7,7 @@
*/
/** Angular Imports */
import { Component, OnInit, ViewChild, inject } from '@angular/core';
import { AfterViewInit, Component, OnInit, ViewChild, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */
@@ -84,7 +84,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
LoanProductPreviewStepComponent
]
})
export class CreateLoanProductComponent implements OnInit {
export class CreateLoanProductComponent implements OnInit, AfterViewInit {
private route = inject(ActivatedRoute);
private productsService = inject(ProductsService);
private alertService = inject(AlertService);
@@ -135,6 +135,8 @@ export class CreateLoanProductComponent implements OnInit {
loanProductAccountingStep: LoanProductAccountingStepComponent;
loanProductsTemplate: any;
/** True when this wizard edits a saved product (the edit route) instead of creating one. */
isEdit = false;
accountingRuleData: string[] = [];
itemsByDefault: any[] = [];
selectedTabIndex = 0;
@@ -158,22 +160,382 @@ export class CreateLoanProductComponent implements OnInit {
constructor() {
const loanProducts = this.loanProducts;
this.route.data.subscribe((data: { loanProductsTemplate: any; configurations: any }) => {
this.loanProductsTemplate = data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.route.data.subscribe(
(data: { loanProductsTemplate: any; loanProductAndTemplate: any; configurations: any }) => {
// The edit route resolves the saved product together with the template options.
this.isEdit = !!data.loanProductAndTemplate;
this.loanProductsTemplate = data.loanProductAndTemplate ?? data.loanProductsTemplate;
const assetAccountData = this.loanProductsTemplate.accountingMappingOptions.assetAccountOptions || [];
const liabilityAccountData = this.loanProductsTemplate.accountingMappingOptions.liabilityAccountOptions || [];
this.loanProductsTemplate.accountingMappingOptions.assetAndLiabilityAccountOptions =
assetAccountData.concat(liabilityAccountData);
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
});
this.itemsByDefault = loanProducts.setItemsByDefault(data.configurations);
this.loanProductsTemplate['itemsByDefault'] = this.itemsByDefault;
if (!this.isEdit) {
this.loanProductsTemplate = loanProducts.updateLoanProductDefaults(this.loanProductsTemplate, false);
}
}
);
}
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.buildAdvancedPaymentAllocation();
// Advances and Loans A/C (EMI) products are filled into the curated tabs. A product made before the type was saved
// uses the old editor instead of an empty form.
const savedGroup = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
if (this.isEdit && savedGroup !== 'advances' && savedGroup !== 'loans') {
this.router.navigate(['../edit-legacy'], { relativeTo: this.route });
}
}
ngAfterViewInit(): void {
if (this.isEdit) {
this.populateWhenReady(0);
}
}
/** The tabs of the product's type appear after the Details step sets the type; fill them once they exist. */
private populateWhenReady(attempt: number): void {
const group = this.loanProductsTemplate?.advanceExtensions?.productTypeGroup;
const ready =
group === 'advances'
? !!(
this.loanProductSecurityLienStep &&
this.loanProductAdvanceLimitTenureStep &&
this.loanProductAdvanceInterestStep &&
this.loanProductRenewalClosureStep &&
this.loanProductPenalNpaStep &&
(!this.isOverdraftFacility || this.loanProductChequeBookStep)
)
: group === 'loans'
? !!(
this.loanProductLoanSecurityStep &&
this.loanProductLoanAmountTenureStep &&
this.loanProductLoanRepaymentStep &&
this.loanProductLoanInterestStep &&
this.loanProductLoanPrepaymentStep &&
this.loanProductPenalNpaStep
)
: false;
if (!ready) {
if (attempt < 60) {
setTimeout(() => this.populateWhenReady(attempt + 1), 150);
}
return;
}
if (group === 'loans') {
this.populateLoans();
} else {
this.populateAdvance();
}
}
/** Reverse of assembleLoanProduct: the saved core fields and the extension record go back into the Loans tabs. */
private populateLoans(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const repayment = this.loanProductLoanRepaymentStep;
const interest = this.loanProductLoanInterestStep;
const frequency = repayment.frequencyOptions.find((o) => o.fineractId === p.repaymentFrequencyType?.id)?.id;
const daysInYear = interest.daysInYearOptions.find((o) => o.fineractId === p.daysInYearType?.id)?.id;
const daysInMonth = interest.daysInMonthOptions.find((o) => o.fineractId === p.daysInMonthType?.id)?.id;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductLoanSecurityStep.loanProductLoanSecurityForm, {
securityType: e.securityType,
securityMandatory: e.securityMandatory,
margin: e.margin,
guarantorsRequired: e.guarantorsRequired,
coApplicant: e.coApplicant,
thirdPartySecurity: e.thirdPartySecurity,
legalOpinionRequired: e.legalOpinionRequired,
propertyInsuranceRequired: e.propertyInsuranceRequired,
registerCharge: e.registerCharge,
autoReleaseOnClosure: e.releaseSecurityOnClosure,
allowMultipleSecurities: e.allowMultipleSecurities,
allowThirdPartyDeposit: e.allowThirdPartyDeposit
});
this.fill(this.loanProductLoanAmountTenureStep.loanProductLoanAmountTenureForm, {
minAmount: p.minPrincipal,
defaultAmount: p.principal,
maxAmount: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
minAge: e.minAge,
maxAgeAtMaturity: e.maxAgeAtMaturity,
minCibil: e.minCibil,
minMonthlyIncome: e.minMonthlyIncome,
maxFoir: e.maxFoir,
maxLoansPerCustomer: e.maxLoansPerCustomer,
memberRequired: e.memberRequired,
guarantorMustBeMember: e.guarantorMustBeMember,
incomeMultiple: e.incomeMultiple,
disbursementType: e.disbursementType,
// The tranche count only applies to a multiple disbursement; a single one leaves the field at its own default.
maxTranches: e.disbursementType === 'multiple' ? p.maxTrancheCount : undefined,
disbursementMode: e.disbursementMode,
undisbursedInterest: e.undisbursedInterest,
preEmiInterest: e.preEmiInterest
});
this.fill(repayment.loanProductLoanRepaymentForm, {
repaymentType: e.repaymentType,
repaidEvery: p.repaymentEvery,
frequency,
minDaysToFirstRepayment: p.minimumDaysBetweenDisbursalAndFirstRepayment,
emiDueDay: e.emiDueDay,
holidayRule: e.holidayRule,
moratoriumMonths: e.moratoriumMonths,
moratoriumInterest: e.moratoriumInterest,
variableInstallments: e.variableInstallments,
repaymentMode: e.repaymentMode,
linkedAccountType: e.linkedAccountType,
pdcCollection: e.pdcCollection,
mandateRequired: e.mandateRequired,
appropriationOrder: e.appropriationOrder,
advanceEmi: e.advanceEmi
});
this.fill(interest.loanProductLoanInterestForm, {
interestType: e.rateType,
registerParticular: e.registerParticular,
applyCibilAdjustment: e.applyCibilAdjustment,
spread: e.spread,
rateResolvedAt: e.rateResolvedAt,
minRate: p.minInterestRatePerPeriod,
defaultRate: p.interestRatePerPeriod,
maxRate: p.maxInterestRatePerPeriod,
calcMethod: e.interestCalcMethod,
daysInYear,
daysInMonth,
calcPeriod: e.calcPeriod,
interestStartsFrom: e.interestStartsFrom,
compounding: e.compounding,
benchmark: e.benchmark,
resetFrequency: e.resetFrequency,
resetChange: e.resetChange
});
this.fill(this.loanProductLoanPrepaymentStep.loanProductLoanPrepaymentForm, {
partPrepaymentAllowed: e.partPrepaymentAllowed,
minPartPayment: e.minPartPayment,
allowedOn: e.allowedOn,
partPaymentsPerYear: e.partPaymentsPerYear,
partPrepaymentMode: e.partPrepaymentMode,
foreclosureFloatingIndividual: e.foreclosureFloatingIndividual,
foreclosureFixedIndividual: e.foreclosureFixedIndividual,
foreclosureNonIndividual: e.foreclosureNonIndividual,
foreclosureTakeover: e.foreclosureTakeover,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure,
foreclosureQuoteValidDays: e.foreclosureQuoteValidDays,
autoCloseOnZeroBalance: e.autoCloseOnZeroBalance,
nocLetter: e.nocLetter,
restructuring: e.restructuring,
topUpAllowed: e.topUpAllowed,
balanceTransferOutAllowed: e.balanceTransferOutAllowed
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears
});
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency empties the Charges list; put the saved charges back, or saving would detach them.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
/** Sets the controls and marks them touched-by-user so a sub type default does not overwrite the saved value. */
private fill(form: UntypedFormGroup | undefined, values: Record<string, any>): void {
if (!form) {
return;
}
Object.keys(values).forEach((name) => {
const control = form.get(name);
const value = values[name];
if (control && value !== undefined && value !== null) {
// A YES / NO select is saved as true / false; give it the word the select understands.
const isYesNoSelect = control.value === 'YES' || control.value === 'NO';
control.setValue(isYesNoSelect && typeof value === 'boolean' ? (value ? 'YES' : 'NO') : value);
control.markAsDirty();
}
});
}
/** Reverse of assembleAdvanceProduct: the saved core fields and the extension record go back into the tabs. */
private populateAdvance(): void {
const p = this.loanProductsTemplate;
const e = p.advanceExtensions ?? {};
const split = (value: any) =>
value
? String(value)
.split('|')
.filter((v) => v !== '')
: undefined;
this.fill(this.loanProductCurrencyStep?.loanProductCurrencyForm, {
currencyCode: p.currency?.code,
digitsAfterDecimal: p.currency?.decimalPlaces
});
this.fill(this.loanProductSecurityLienStep.loanProductSecurityLienForm, {
securityType: e.securityType,
eligibleAmountBasis: e.eligibleAmountBasis,
margin: e.margin,
lienMarkingStage: e.lienMarkingStage,
eligibleDepositProducts: split(e.eligibleDepositProducts),
tenureNotExceedMaturity: e.lienTenureNotExceedMaturity,
autoReleaseOnClosure: e.lienAutoReleaseOnClosure,
allowMultipleDeposits: e.lienAllowMultipleDeposits,
adjustFromMaturity: e.lienAdjustFromMaturity,
allowThirdPartyDeposit: e.lienAllowThirdParty,
allowRenewalUnderLien: e.lienAllowRenewalUnderLien,
lienCertificateRequired: e.lienCertificateRequired,
lienCertificatePrefix: e.lienCertificatePrefix,
printOnDisbursement: e.printCertificateOnDisbursement,
goldMaxLtv: e.goldMaxLtv,
goldMinPurityCarat: e.goldMinPurityCarat,
goldRateSource: e.goldRateSource,
goldMaxPerGram: e.goldMaxPerGram,
goldMinNetWeight: e.goldMinNetWeight,
goldMaxNetWeightPerCustomer: e.goldMaxNetWeightPerCustomer,
goldAllowedItems: e.goldAllowedItems,
goldValuationCertificateRequired: e.goldValuationCertificateRequired,
goldMaxValuationPerAppraiser: e.goldMaxValuationPerAppraiser,
goldPacketPrefix: e.goldPacketPrefix,
goldRevalueOnNpa: e.goldRevalueOnNpa,
goldRevaluationChargeApplicable: e.goldRevaluationChargeApplicable,
insuranceRequired: e.insuranceRequired,
goldAuctionNoticeDays: e.goldAuctionNoticeDays,
goldPartialReleaseAllowed: e.goldPartialReleaseAllowed,
goldThirdPartyAllowed: e.goldThirdPartyAllowed,
goldDisbursementMode: e.goldDisbursementMode,
goldCashDisbursementLimit: e.goldCashDisbursementLimit,
goldLtvBreachAlert: e.goldLtvBreachAlert,
goldAuctionReservePct: e.goldAuctionReservePct,
wrMaxLtv: e.wrMaxLtv,
wrReceiptType: e.wrReceiptType,
wrTenureWithinReceiptValidity: e.wrTenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: e.wrPledgeMarkedBeforeDisbursement,
wrCommodityGroups: split(e.wrCommodityGroups),
wrValuationPriceSource: e.wrValuationPriceSource,
wrMaxLoanPerQuintal: e.wrMaxLoanPerQuintal,
wrStockInspectionMonths: e.wrStockInspectionMonths,
wrLtvBreachAlert: e.wrLtvBreachAlert,
wrPartialReleaseAllowed: e.wrPartialReleaseAllowed,
wrDisposalNoticeDays: e.wrDisposalNoticeDays,
wrDisposalReservePct: e.wrDisposalReservePct
});
this.fill(this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenureForm, {
facilityType: e.facilityType,
minLimit: p.minPrincipal,
defaultLimit: p.principal,
maxLimit: p.maxPrincipal,
minTenure: p.minNumberOfRepayments,
defaultTenure: p.numberOfRepayments,
maxTenure: p.maxNumberOfRepayments,
repaymentType: e.repaymentType,
appropriationOrder: e.appropriationOrder,
debitBalanceAllow: e.debitBalanceAllow,
allowDebitTransAfterDueDate: e.allowDebitAfterDueDate,
branchLevelAccountType: e.branchLevelAccountType
});
this.fill(this.loanProductAdvanceInterestStep.loanProductAdvanceInterestForm, {
interestBasis: e.interestBasis,
registerParticular: e.registerParticular,
spread: e.spread,
calcMethod: e.interestCalcMethod,
interestChargedOn: e.interestChargedOn,
interestOnCreditBalance: e.interestOnCreditBalance,
postingFrequency: e.interestPostingFrequency,
postingDay: e.interestPostingDay
});
this.fill(this.loanProductRenewalClosureStep.loanProductRenewalClosureForm, {
renewalAllowed: e.renewalAllowed,
renewalPeriodMonths: e.renewalPeriodMonths,
renewalGraceDays: e.renewalGraceDays,
preclosureAllowed: e.preclosureAllowed,
lockInMonths: e.lockInMonths,
interestOnPreclosure: e.interestOnPreclosure
});
this.fill(this.loanProductPenalNpaStep.loanProductPenalNpaForm, {
penalApplicable: e.penalApplicable,
penalRate: e.penalRate,
penalAppliedOn: e.penalAppliedOn,
penalGraceDays: e.penalGraceDays,
delinquencyBucketId: p.delinquencyBucket?.id,
npaDays: p.overdueDaysForNPA,
npaTrigger: e.npaTrigger,
interestAfterNpa: e.interestAfterNpa,
smaStartAfterMonths: e.smaStartAfterMonths,
stockStatementValidityDays: e.stockStatementValidityDays,
inArrearsTolerance: p.inArrearsTolerance,
upgradeOnlyAllArrears: e.npaUpgradeOnlyAllArrears,
keepStandardIfDepositCovers: e.npaKeepStandardIfDepositCovers,
npaOutOfOrderBasis: e.npaOutOfOrderBasis,
cropDuration: e.cropDuration
});
if (this.isOverdraftFacility && this.loanProductChequeBookStep) {
this.fill(this.loanProductChequeBookStep.loanProductChequeBookForm, {
chequeType: e.chequeType,
bookSizes: split(e.chequeBookSizes),
defaultBookSize: e.defaultChequeBookSize !== undefined ? String(e.defaultChequeBookSize) : undefined,
maxBooksOutstanding: e.chequeMaxBooksOutstanding,
firstBookFree: e.chequeFirstBookFree,
freeBooksPerYear: e.chequeFreeBooksPerYear,
namePrinted: e.chequeNamePrinted,
ppsThreshold: e.ppsThreshold,
ppsMandatoryAbove: e.ppsMandatoryAbove,
stopPaymentAllowed: e.chequeStopPaymentAllowed,
maintainStockRegister: e.chequeMaintainStockRegister,
allowWithdrawalSlip: e.chequeAllowWithdrawalSlip,
blockNpa: e.chequeBlockNpa,
blockLimitExpired: e.chequeBlockLimitExpired,
blockOverDp: e.chequeBlockOverDp,
blockFrozen: e.chequeBlockFrozen,
blockLienReleased: e.chequeBlockLienReleased
});
}
// The nine GLs: the core ones are filled by the Accounting step from the saved mappings; the suspense GL is
// kept on the extension record.
this.fill(this.loanProductAccountingStep?.loanProductAccountingForm, {
undebitedInterestAccountId: e.undebitedInterestGlId
});
// Filling the currency above empties the Charges list (a new currency invalidates it); put the saved charges back,
// or saving the edit would detach every charge from the product.
if (this.loanProductChargesStep) {
this.loanProductChargesStep.chargesDataSource = [...(p.charges ?? [])];
}
}
get loanProductDetailsForm() {
@@ -305,6 +667,12 @@ export class CreateLoanProductComponent implements OnInit {
return this.loanProductAccountingStep.loanProductAccountingForm;
}
/** True once the Details step has both a product type and a sub type; every other tab stays locked until then. */
get hasProductType(): boolean {
const details = this.loanProductDetailsForm;
return !!details?.get('productTypeGroup')?.value && !!details?.get('productTypeSubGroup')?.value;
}
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
@@ -316,14 +684,14 @@ export class CreateLoanProductComponent implements OnInit {
}
get totalTabs(): number {
let tabs = 4; // details, currency, charges, accounting (always)
let tabs = 5; // details, currency, penal & npa, charges, accounting (always)
if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
// security & lien, limit & tenure, interest, renewal & closure
tabs += 4;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else if (this.isLoanAccount) {
// security & collateral, amount & tenure, repayment & emi, interest, penal & npa, prepayment & closure
tabs += 6;
// security & collateral, amount & tenure, repayment & emi, interest, prepayment & closure
tabs += 5;
}
// Old Loans A/C path (Settings, Terms, Interest Refund, Payment Allocation, Deferred Income) — commented out:
// else {
@@ -334,9 +702,114 @@ export class CreateLoanProductComponent implements OnInit {
return tabs;
}
/**
* The tabs that still block the Review tab, each with the fields to fix. The Review tab only appears once every
* tab is valid, and a tab that was never opened or a missing field is otherwise invisible to the user.
*/
get incompleteTabs(): { tab: string; fields: string[] }[] {
const humanize = (name: string) =>
name
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/Id$/, '')
.replace(/^./, (ch) => ch.toUpperCase());
const tabs: [string, any][] = [
[
'Details',
this.loanProductDetailsForm
],
[
'Currency',
this.loanProductCurrencyForm
]
];
if (this.isAdvanceProduct) {
tabs.push(
[
'Security & Lien',
this.loanProductSecurityLienStep?.loanProductSecurityLienForm
],
[
'Limit & Tenure',
this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm
],
[
'Interest',
this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm
],
[
'Renewal & Closure',
this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm
]
);
if (this.isOverdraftFacility) {
tabs.push([
'Cheque Book',
this.loanProductChequeBookStep?.loanProductChequeBookForm
]);
}
} else if (this.isLoanAccount) {
tabs.push(
[
'Security & Collateral',
this.loanProductLoanSecurityStep?.loanProductLoanSecurityForm
],
[
'Amount & Tenure',
this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm
],
[
'Repayment & EMI',
this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm
],
[
'Interest',
this.loanProductLoanInterestStep?.loanProductLoanInterestForm
],
[
'Prepayment & Closure',
this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm
]
);
}
tabs.push(
[
'Penal & NPA',
this.loanProductPenalNpaStep?.loanProductPenalNpaForm
],
[
'Accounting',
this.loanProductAccountingStep?.loanProductAccountingForm
]
);
return tabs
.filter(
([
,
form
]) => !form || form.invalid
)
.map(
([
tab,
form
]) => ({
tab,
fields: form
? Object.keys(form.controls)
.filter((key) => form.controls[key].invalid)
.slice(0, 4)
.map(humanize)
: []
})
);
}
get loanProductFormValid() {
const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductAccountingForm.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
@@ -345,7 +818,6 @@ export class CreateLoanProductComponent implements OnInit {
!!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
!!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
!!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
(!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
);
@@ -359,7 +831,6 @@ export class CreateLoanProductComponent implements OnInit {
!!this.loanProductLoanAmountTenureStep?.loanProductLoanAmountTenureForm?.valid &&
!!this.loanProductLoanRepaymentStep?.loanProductLoanRepaymentForm?.valid &&
!!this.loanProductLoanInterestStep?.loanProductLoanInterestForm?.valid &&
!!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
!!this.loanProductLoanPrepaymentStep?.loanProductLoanPrepaymentForm?.valid
);
}
@@ -375,6 +846,33 @@ export class CreateLoanProductComponent implements OnInit {
// return base && loansValid;
}
private previewKey = '';
private previewProduct: any = {};
/**
* The product the Review tab shows. It is rebuilt only when a form value changes, so the summary gets a stable object
* (it fills in currency, schedule type and so on on the object it receives). Submit still uses the fresh `loanProduct`.
*/
get previewLoanProduct(): any {
const key = JSON.stringify(
Object.keys(this)
.filter((name) => /Step$/.test(name))
.map((name) => {
const step = (this as any)[name];
return step
? Object.keys(step)
.filter((prop) => /Form$/.test(prop))
.map((prop) => step[prop]?.getRawValue?.())
: null;
})
);
if (key !== this.previewKey) {
this.previewKey = key;
this.previewProduct = this.loanProduct;
}
return this.previewProduct;
}
get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
@@ -535,9 +1033,14 @@ export class CreateLoanProductComponent implements OnInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
interestAfterNpa: pn.interestAfterNpa,
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent. "Out of order" (CC / OD only) and the
// deposit-cover rule do not apply to a term loan, so neither is sent.
cropDuration: pn.cropDuration || undefined,
securityMandatory: sec.securityMandatory,
guarantorsRequired: num(sec.guarantorsRequired),
coApplicant: sec.coApplicant,
@@ -554,6 +1057,9 @@ export class CreateLoanProductComponent implements OnInit {
minMonthlyIncome: num(at.minMonthlyIncome),
maxFoir: num(at.maxFoir),
maxLoansPerCustomer: num(at.maxLoansPerCustomer),
memberRequired: at.memberRequired,
guarantorMustBeMember: at.guarantorMustBeMember,
incomeMultiple: num(at.incomeMultiple),
disbursementType: at.disbursementType,
disbursementMode: at.disbursementMode,
undisbursedInterest: at.undisbursedInterest,
@@ -591,7 +1097,6 @@ export class CreateLoanProductComponent implements OnInit {
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
@@ -613,6 +1118,9 @@ export class CreateLoanProductComponent implements OnInit {
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lien = this.loanProductSecurityLienStep.isLienSubType;
const gold = this.loanProductSecurityLienStep.isGold ? sec.gold : null;
const wr = this.loanProductSecurityLienStep.isWarehouse ? sec.warehouse : null;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
@@ -688,10 +1196,62 @@ export class CreateLoanProductComponent implements OnInit {
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
// Lien settings exist only for a deposit / policy sub type; a pledged one (Gold ...) sends none of them.
...(lien
? {
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement
}
: {}),
...(gold
? {
goldMaxLtv: num(gold.maxLtv),
goldMinPurityCarat: num(gold.minPurityCarat),
goldRateSource: gold.rateSource,
goldMaxPerGram: num(gold.maxPerGram),
goldMinNetWeight: num(gold.minNetWeight),
goldMaxNetWeightPerCustomer: num(gold.maxNetWeightPerCustomer),
goldAllowedItems: gold.allowedItems,
goldAppraiserRequired: true,
goldValuationCertificateRequired: gold.valuationCertificateRequired === 'YES',
goldMaxValuationPerAppraiser: num(gold.maxValuationPerAppraiser),
goldPacketPrefix: gold.packetPrefix || undefined,
goldRevalueOnNpa: gold.revalueOnNpa,
goldRevaluationChargeApplicable: gold.revaluationChargeApplicable,
insuranceRequired: gold.insuranceRequired === 'YES',
goldAuctionNoticeDays: num(gold.auctionNoticeDays),
goldPartialReleaseAllowed: gold.partialReleaseAllowed,
goldThirdPartyAllowed: gold.thirdPartyAllowed,
goldDisbursementMode: gold.disbursementMode,
goldCashDisbursementLimit:
gold.disbursementMode === 'account_credit_cash_limit' ? num(gold.cashDisbursementLimit) : undefined,
goldLtvBreachAlert: num(gold.ltvBreachAlert),
goldAuctionReservePct: num(gold.auctionReservePct),
goldRedeemBeforeAuction: true
}
: {}),
...(wr
? {
wrMaxLtv: num(wr.maxLtv),
wrReceiptType: wr.receiptType,
wrWdraRegistered: true,
wrAssayCertificateRequired: true,
wrTenureWithinReceiptValidity: wr.tenureWithinReceiptValidity,
wrPledgeMarkedBeforeDisbursement: wr.pledgeMarkedBeforeDisbursement,
wrCommodityGroups: (wr.commodityGroups ?? []).join('|'),
wrValuationPriceSource: wr.valuationPriceSource,
wrMaxLoanPerQuintal: num(wr.maxLoanPerQuintal),
wrStockInspectionMonths: num(wr.stockInspectionMonths),
insuranceRequired: wr.insuranceRequired === 'YES',
wrLtvBreachAlert: num(wr.ltvBreachAlert),
wrPartialReleaseAllowed: wr.partialReleaseAllowed,
wrDisposalNoticeDays: num(wr.disposalNoticeDays),
wrDisposalReservePct: num(wr.disposalReservePct),
wrRedeemBeforeDisposal: true
}
: {}),
interestBasis: intr.interestBasis,
registerParticular: intr.registerParticular || undefined,
spread: num(intr.spread),
@@ -702,7 +1262,14 @@ export class CreateLoanProductComponent implements OnInit {
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
smaStartAfterMonths: num(pn.smaStartAfterMonths),
stockStatementValidityDays: num(pn.stockStatementValidityDays),
interestAfterNpa: pn.interestAfterNpa,
// The SICR & LGD (ECL) card is hidden for now, so its values are not sent.
npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined,
cropDuration: pn.cropDuration || undefined,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
@@ -716,14 +1283,18 @@ export class CreateLoanProductComponent implements OnInit {
ppsMandatoryAbove: num(cb?.ppsMandatoryAbove),
// ---- remaining wizard fields ----
// security & lien extras (deposit list joined; rule checkboxes)
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien,
// security & lien extras (deposit list joined; rule checkboxes), only for a deposit / policy sub type
...(lien
? {
eligibleDepositProducts: (sec.eligibleDepositProducts ?? []).join('|'),
lienTenureNotExceedMaturity: sec.lienRules?.tenureNotExceedMaturity,
lienAutoReleaseOnClosure: sec.lienRules?.autoReleaseOnClosure,
lienAllowMultipleDeposits: sec.lienRules?.allowMultipleDeposits,
lienAdjustFromMaturity: sec.lienRules?.adjustFromMaturity,
lienAllowThirdParty: sec.lienRules?.allowThirdPartyDeposit,
lienAllowRenewalUnderLien: sec.lienRules?.allowRenewalUnderLien
}
: {}),
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
@@ -736,15 +1307,13 @@ export class CreateLoanProductComponent implements OnInit {
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
...(lien ? { npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers } : {}),
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
@@ -771,6 +1340,19 @@ export class CreateLoanProductComponent implements OnInit {
delete loanProduct['daysInYearCustomStrategy'];
}
if (this.isEdit) {
this.productsService.updateLoanProduct(this.loanProductsTemplate.id, loanProduct).subscribe((response: any) => {
if (response?.rollbackTransaction) {
this.alertService.alert({
type: 'Success',
message: 'Loan product update request submitted successfully for approval.'
});
}
this.router.navigate(['../'], { relativeTo: this.route });
});
return;
}
this.productsService.createLoanProduct(loanProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -0,0 +1,79 @@
/**
* 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/.
*/
/**
* A saved Advances / Loans product keeps most of its settings in an extension record (`advanceExtensions`), under the
* same names the edit form submits. Putting those next to the core fields gives every submitted field a current value
* to compare against, so a pending edit shows only what really changed. Core fields win on a name clash.
*
* The product read leaves out a core number that is zero, while the edit form always sends it, so that one is filled in
* as 0 rather than reading as "newly set" on every edit.
*/
export function withExtensions(product: any): any {
if (!product) {
return product;
}
const merged = product.advanceExtensions ? { ...product.advanceExtensions, ...product } : { ...product };
if (merged.inArrearsTolerance === undefined || merged.inArrearsTolerance === null) {
merged.inArrearsTolerance = 0;
}
// Same for the arrears grace: a Loans A/C (EMI) product counts arrears from day one, and the read leaves a zero out.
if (merged.graceOnArrearsAgeing === undefined || merged.graceOnArrearsAgeing === null) {
merged.graceOnArrearsAgeing = 0;
}
return merged;
}
/** Core accounting mapping name on the product read -> the payload name the Review reader uses. */
const GL_MAPPING_KEYS: Record<string, string> = {
loanPortfolioAccount: 'loanPortfolioAccountId',
fundSourceAccount: 'fundSourceAccountId',
interestOnLoanAccount: 'interestOnLoanAccountId',
incomeFromFeeAccount: 'incomeFromFeeAccountId',
incomeFromPenaltyAccount: 'incomeFromPenaltyAccountId',
writeOffAccount: 'writeOffAccountId',
receivableInterestAccount: 'receivableInterestAccountId',
overpaymentLiabilityAccount: 'overpaymentLiabilityAccountId'
};
/** A date the API sends as [2026, 10, 15] shown as 15-10-2026. */
function dateText(value: any): string | undefined {
if (!Array.isArray(value) || value.length < 3) {
return value ?? undefined;
}
const [
year,
month,
day
] = value;
return `${String(day).padStart(2, '0')}-${String(month).padStart(2, '0')}-${year}`;
}
/**
* A saved Advances product as the read-only Review page needs it: the flat field names the create wizard submits
* (core fields plus the extension record), the GL mappings as ids with a matching option list so each shows by name,
* and the attached charges as one readable line. Pure; the product object is not changed.
*/
export function reviewProductOf(product: any): { product: any; template: any } {
const flat: any = { ...withExtensions(product) };
const accounts: any[] = [];
Object.keys(GL_MAPPING_KEYS).forEach((name) => {
const account = product?.accountingMappings?.[name];
if (account?.id !== undefined) {
flat[GL_MAPPING_KEYS[name]] = account.id;
accounts.push({ id: account.id, glCode: account.glCode, name: account.name });
}
});
flat.currencyCode = product?.currency?.code;
flat.digitsAfterDecimal = product?.currency?.decimalPlaces;
flat.startDate = dateText(product?.startDate);
flat.closeDate = dateText(product?.closeDate);
flat.delinquencyBucketId = undefined;
flat.chargesSummary = (product?.charges ?? []).map((charge: any) => `${charge.name} (${charge.amount})`).join(', ');
return { product: flat, template: { accountingMappingOptions: { assetAccountOptions: accounts } } };
}
@@ -214,3 +214,242 @@ $gray-900: #202124;
grid-template-columns: 1fr;
}
}
/* Penal / SMA / NPA / ECL cards: each gets its own accent so they read as separate blocks. */
.pn-card {
border-left-width: 4px;
&--penal {
border-left-color: #0ea5e9;
}
&--sma {
border-left-color: #f59e0b;
}
&--npa {
border-left-color: #dc2626;
}
&--ecl {
border-left-color: #6366f1;
}
}
.pn-stages {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 0 16px 16px;
}
.pn-stage {
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
&--0 {
background: #fef9c3;
color: #854d0e;
}
&--1 {
background: #ffedd5;
color: #9a3412;
}
&--2 {
background: #fee2e2;
color: #991b1b;
}
}
/* ECL card: collapsed by default, grouped, plain wording. */
.pn-ecl-head {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
padding: 14px 16px;
border: 0;
background: #f8fafc;
color: #1e293b;
font: inherit;
text-align: left;
cursor: pointer;
&:hover {
background: #f1f5f9;
}
&__title {
display: inline-flex;
align-items: center;
gap: 10px;
white-space: nowrap;
i {
color: #6366f1;
}
}
&__tag {
padding: 2px 8px;
border-radius: 999px;
background: #e0e7ff;
color: #3730a3;
font-size: 11px;
font-weight: 600;
}
&__summary {
flex: 1;
min-width: 0;
overflow: hidden;
color: #64748b;
font-size: 12px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.pn-ecl-body {
padding: 4px 16px 16px;
}
.pn-ecl-intro {
margin: 8px 0 16px;
color: #475569;
font-size: 13px;
line-height: 1.5;
}
.pn-ecl-group {
margin-bottom: 20px;
h4 {
margin: 0 0 12px;
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
}
.pn-ecl-row {
margin-top: 12px;
}
.pn-ecl-check {
display: flex;
align-items: center;
min-height: 40px;
}
/* Eligibility & Documents tab. */
.ed-body {
padding: 16px;
}
.ed-count {
margin-left: auto;
padding: 2px 10px;
border-radius: 999px;
background: #e0f2fe;
color: #075985;
font-size: 12px;
font-weight: 600;
}
.ed-rules {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-rule {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
cursor: pointer;
&__text {
display: flex;
flex-direction: column;
gap: 2px;
strong {
color: #1e293b;
font-size: 14px;
font-weight: 600;
}
small {
color: #64748b;
font-size: 12px;
}
}
}
.ed-group {
margin-bottom: 18px;
&:last-child {
margin-bottom: 0;
}
&__title {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
color: #475569;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
i {
color: #6366f1;
}
}
}
.ed-docs {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px 12px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
.ed-doc {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 12px 4px 4px;
border: 1px solid #e2e8f0;
border-radius: 8px;
color: #334155;
font-size: 14px;
cursor: pointer;
&:hover {
background: #f8fafc;
}
&--on {
border-color: #a5b4fc;
background: #eef2ff;
color: #1e1b4b;
}
}
@@ -38,18 +38,12 @@
<div class="lpa-group">
<div class="lpa-group__header">
<h4>{{ section.title }}</h4>
<button
type="button"
class="lpa-add-other-btn"
(click)="addOtherGlMapping(section.otherMappingsControlName)"
>
+ Add Other
</button>
</div>
<div class="lpa-list">
@for (row of section.rows; track row.key) {
@if (
(!row.accrualOnly || isAccrualAccounting()) &&
basicControls.has(row.controlName) &&
(!row.accrualOnly || isAccrualAccounting() || basicControls.has(row.controlName)) &&
(!row.deferredOnly || isDeferredOnly()) &&
(!row.deferredCapitalizationOnly || isDeferredCapitalizationOnly()) &&
(!row.deferredBuyDownOnly || isDeferredBuyDownOnly()) &&
@@ -78,334 +72,6 @@
}
}
</div>
<!-- Other GL Mappings -->
<div class="lpa-list" [formArrayName]="section.otherMappingsControlName">
@for (
otherRow of getOtherGlMappingsArray(section.otherMappingsControlName).controls;
track $index;
let i = $index
) {
<div class="lpa-other-row-wrap">
<div class="lpa-row lpa-row--other" [formGroupName]="i">
<div class="lpa-row__meta lpa-row__meta--other">
<span
class="lpa-pill"
[class.lpa-pill--green]="section.pillClass === 'green'"
[class.lpa-pill--amber]="section.pillClass === 'amber'"
[class.lpa-pill--blue]="section.pillClass === 'blue'"
>
{{ section.pillLabel }}
</span>
<input
class="lpa-row__name lpa-row__name--other-account"
formControlName="accountName"
placeholder="Enter account name..."
type="text"
/>
</div>
<div class="lpa-row__actions lpa-row__actions--other">
<mat-form-field class="lpa-row__other-field" appearance="outline" subscriptSizing="dynamic">
<mat-label>GL Code</mat-label>
<mat-select
[formControl]="getOtherGlMappingControl(section.otherMappingsControlName, i, 'glAccountId')"
>
@for (glAccount of section.otherAccountOptions; track glAccount.id) {
<mat-option [value]="glAccount.id">
{{ '(' + glAccount.glCode + ') ' + glAccount.name }}
</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<button
type="button"
class="lpa-remove-btn lpa-remove-btn--side"
(click)="removeOtherGlMapping(section.otherMappingsControlName, i)"
>
<i class="fas fa-times"></i>
</button>
</div>
}
</div>
</div>
}
<!-- Advanced Accounting Rules Section -->
@if (loanProductAccountingForm.value.accountingRule >= 2 && loanProductAccountingForm.value.accountingRule <= 4) {
<div class="lpa-group">
<div class="lpa-group__header">
<h4>Advanced Accounting Rules</h4>
</div>
<div class="lpa-checkbox-row">
<mat-checkbox formControlName="advancedAccountingRules">{{
'labels.heading.Advanced Accounting Rules' | translate
}}</mat-checkbox>
</div>
@if (loanProductAccountingForm.value.advancedAccountingRules) {
<!-- Payment Fund Sources -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Configure Fund Sources for Payment Channels' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PaymentFundSource', paymentChannelToFundSourceMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (paymentChannelToFundSourceMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="paymentChannelToFundSourceMappings.value">
<ng-container matColumnDef="paymentTypeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Payment Type' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.paymentTypeId | find: paymentTypeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="fundSourceAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fund Source' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource">
{{ paymentFundSource.fundSourceAccountId | find: assetAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let paymentFundSource; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PaymentFundSource', paymentChannelToFundSourceMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="paymentFundSourceDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: paymentFundSourceDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Fees to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Fees to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('FeesIncome', feeToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (feeToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="feeToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Fees' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.chargeId | find: chargeData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome">
{{ feesIncome.incomeAccountId | find: incomeAndLiabilityAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let feesIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('FeesIncome', feeToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Penalties to Income -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Penalties to Specific Income Accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
(click)="add('PenaltyIncome', penaltyToIncomeAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (penaltyToIncomeAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="penaltyToIncomeAccountMappings.value">
<ng-container matColumnDef="chargeId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Penalty' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.chargeId | find: penaltyData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="incomeAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Income Account' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome">
{{ penaltyIncome.incomeAccountId | find: incomeAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let penaltyIncome; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('PenaltyIncome', penaltyToIncomeAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="feesPenaltyIncomeDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: feesPenaltyIncomeDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Charge-off Reasons to Expense -->
<div class="lpa-subsection">
<div class="lpa-subsection-header">
<h4>{{ 'labels.heading.Map Charge-off reasons to Expense accounts' | translate }}</h4>
<button
type="button"
mat-raised-button
color="primary"
class="lpa-add-btn"
[disabled]="!allowAddChargeOffReasonExpense"
(click)="add('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings)"
>
<fa-icon icon="plus" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Add' | translate }}
</button>
</div>
@if (chargeOffReasonToExpenseAccountMappings.value.length !== 0) {
<div class="lpa-table-wrapper">
<table mat-table [dataSource]="chargeOffReasonToExpenseAccountMappings.value">
<ng-container matColumnDef="chargeOffReasonCodeValueId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Charge-off reason' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{
chargeOffReasonExpense.chargeOffReasonCodeValueId
| find: chargeOffReasonOptions : 'id' : 'name'
}}
</td>
</ng-container>
<ng-container matColumnDef="expenseAccountId">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Expense Account' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense">
{{ chargeOffReasonExpense.expenseAccountId | find: expenseAccountData : 'id' : 'name' }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell *matCellDef="let chargeOffReasonExpense; let i = index">
<button
mat-icon-button
color="primary"
(click)="edit('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="edit"></fa-icon>
</button>
<button
mat-icon-button
color="warn"
(click)="delete('ChargeOffReasonExpense', chargeOffReasonToExpenseAccountMappings, i)"
>
<fa-icon icon="trash"></fa-icon>
</button>
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="chargeOffReasonExpenseDisplayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: chargeOffReasonExpenseDisplayedColumns"></tr>
</table>
</div>
}
</div>
<!-- Advanced Mapping Rule Components -->
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'BuydownFeeClassificationToIncome'"
[formArray]="buydownfeeClassificationToIncomeAccountMappings"
[textHeading]="'Buydown Fee classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="buydownFeeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'Classification'"
[formType]="'CapitalizedIncomeClassificationToIncome'"
[formArray]="capitalizedIncomeClassificationToIncomeAccountMappings"
[textHeading]="'Capitalized Income classifications to Income accounts'"
[incomeAccountData]="incomeAccountData"
[accountingMappingOptions]="capitalizedIncomeClassificationOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
<mifosx-advanced-accounting-mapping-rule
class="lpa-full-width"
[textField]="'WriteOff Reason'"
[formType]="'WriteOffReasonToExpense'"
[formArray]="writeOffReasonsToExpenseMappings"
[textHeading]="'WriteOff reasons to Expense accounts'"
[expenseAccountData]="expenseAccountData"
[accountingMappingOptions]="writeOffReasonOptions"
(formChangeEvent)="formChangeEvent($event)"
></mifosx-advanced-accounting-mapping-rule>
}
</div>
}
</div>
@@ -104,6 +104,23 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
@Input() accountingRuleData: any;
@Input() loanProductFormValid: boolean;
@Input() deferredIncomeRecognition: DeferredIncomeRecognition;
/** The product's category / type and its portfolio GL, from the Details step; they drive the default GL mapping. */
@Input() productTypeGroup: string | null = null;
@Input() productTypeSubGroup: string | null = null;
@Input() productGlAccountId: number | string | null = null;
/** The mappings every product needs; the rest default from these and sit behind "Show all mappings". */
readonly basicControls = new Set<string>([
'loanPortfolioAccountId',
'fundSourceAccountId',
'interestOnLoanAccountId',
'incomeFromFeeAccountId',
'incomeFromPenaltyAccountId',
'writeOffAccountId',
'receivableInterestAccountId',
'overpaymentLiabilityAccountId',
'undebitedInterestAccountId'
]);
public loanProductAccountingForm!: UntypedFormGroup;
@@ -151,6 +168,73 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
if (this.loanProductAccountingForm) {
this.setDeferredIncomeRecognitionControls();
}
if (changes['productTypeGroup'] || changes['productTypeSubGroup'] || changes['productGlAccountId']) {
this.applyDefaultMapping();
}
}
/** The id of the GL account with this GL code among all the accounting options, or null. */
private glIdByCode(code: string): number | null {
const all = [
...(this.assetAccountData ?? []),
...(this.incomeAccountData ?? []),
...(this.expenseAccountData ?? []),
...(this.liabilityAccountData ?? [])
];
return all.find((account: any) => String(account.glCode) === code)?.id ?? null;
}
/**
* Pre-fills the GL mappings from the product type: the portfolio GL is the product's own GL, interest income follows
* the loan term (Advances short, Loans medium, Housing long), the rest are the bank's shared GLs. A mapping the
* officer has already changed is left alone.
*/
applyDefaultMapping(): void {
// A saved product keeps its own mappings; one saved without any still gets the defaults.
const hasSavedMappings = !!this.loanProductsTemplate?.id && !!this.loanProductsTemplate?.accountingMappings;
if (!this.loanProductAccountingForm || !this.assetAccountData || hasSavedMappings) {
return;
}
const isAdvance = this.productTypeGroup === 'advances';
const interestCode = isAdvance ? '400301' : this.productTypeSubGroup === 'housing' ? '400303' : '400302';
const cash = this.glIdByCode('100101');
const interest = this.glIdByCode(interestCode);
const fee = this.glIdByCode('400106') ?? this.glIdByCode('400101');
const penal = this.glIdByCode('400304') ?? this.glIdByCode('400101');
const writeOff = this.glIdByCode('500503');
const receivable = this.glIdByCode('100601');
const overpayment = this.glIdByCode('200907');
const suspense = this.glIdByCode('200908');
const defaults: Record<string, number | string | null> = {
loanPortfolioAccountId: this.productGlAccountId || null,
fundSourceAccountId: cash,
transfersInSuspenseAccountId: cash,
interestOnLoanAccountId: interest,
incomeFromFeeAccountId: fee,
incomeFromPenaltyAccountId: penal,
incomeFromRecoveryAccountId: interest,
writeOffAccountId: writeOff,
goodwillCreditAccountId: writeOff,
chargeOffFraudExpenseAccountId: writeOff,
chargeOffExpenseAccountId: writeOff,
incomeFromChargeOffPenaltyAccountId: penal,
incomeFromChargeOffFeesAccountId: fee,
incomeFromChargeOffInterestAccountId: interest,
incomeFromGoodwillCreditInterestAccountId: interest,
incomeFromGoodwillCreditFeesAccountId: fee,
incomeFromGoodwillCreditPenaltyAccountId: penal,
receivableInterestAccountId: receivable,
receivableFeeAccountId: receivable,
receivablePenaltyAccountId: receivable,
overpaymentLiabilityAccountId: overpayment,
undebitedInterestAccountId: suspense
};
Object.keys(defaults).forEach((name) => {
const control = this.loanProductAccountingForm.get(name);
if (control && !control.dirty && defaults[name]) {
control.setValue(defaults[name]);
}
});
}
ngOnInit() {
@@ -177,6 +261,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
this.loanProductAccountingForm.get('accountingRule').disable();
this.buildAccountingSectionConfigs();
this.applyDefaultMapping();
const accountingMappings = this.loanProductsTemplate.accountingMappings;
this.setDeferredIncomeRecognitionControls();
@@ -277,6 +362,33 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
'intPayableGlAccountId',
'deafIntPayableGlAccountId',
'intOverdueReserveAccountId',
'undebitedInterestAccountId',
'penalIntCrGlAccountId',
'rdMaturedGlAccountId'
];
// CBS-specific mappings the backend does not accept yet: optional here so a product can be created without
// them (empty values are dropped from the payload in LoanProducts.buildPayload).
const optionalCbsFields = [
// derived by the backend from the nine mapped GLs (LoanGlMappingDefaults)
'transfersInSuspenseAccountId',
'incomeFromRecoveryAccountId',
'goodwillCreditAccountId',
'chargeOffFraudExpenseAccountId',
'chargeOffExpenseAccountId',
'incomeFromChargeOffPenaltyAccountId',
'incomeFromChargeOffFeesAccountId',
'incomeFromChargeOffInterestAccountId',
'incomeFromGoodwillCreditInterestAccountId',
'incomeFromGoodwillCreditFeesAccountId',
'incomeFromGoodwillCreditPenaltyAccountId',
'receivableInterestAccountId',
'receivableFeeAccountId',
'receivablePenaltyAccountId',
'intReceivablesGlAccountId',
@@ -289,7 +401,10 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
];
commonFields.forEach((field) => {
if (!this.loanProductAccountingForm.contains(field)) {
this.loanProductAccountingForm.addControl(field, new UntypedFormControl('', Validators.required));
this.loanProductAccountingForm.addControl(
field,
new UntypedFormControl('', optionalCbsFields.includes(field) ? null : Validators.required)
);
}
});
@@ -567,8 +682,20 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
} = this.loanProductAccountingForm.getRawValue();
const otherGlMappings = this.getOtherGlMappings();
// Only the nine mapped GLs are sent. The other Fineract placeholders (recovery, goodwill credit, charge-off ...)
// are derived by the backend. Interest Suspense has no core mapping, so it is kept on the satellite record.
const payload: any = {};
this.basicControls.forEach((name) => {
if (name !== 'undebitedInterestAccountId' && loanProductAccounting[name]) {
payload[name] = loanProductAccounting[name];
}
});
if (loanProductAccounting.undebitedInterestAccountId) {
payload.undebitedInterestGlId = loanProductAccounting.undebitedInterestAccountId;
}
return {
...loanProductAccounting,
accountingRule: loanProductAccounting.accountingRule,
...payload,
...(otherGlMappings ? { otherGlMappings } : {})
};
}
@@ -684,7 +811,7 @@ export class LoanProductAccountingStepComponent implements OnInit, OnChanges {
inputLabel: 'Interest Receivable',
controlName: 'receivableInterestAccountId',
options: this.assetAccountData,
required: true,
required: false,
accrualOnly: true
},
{
@@ -61,12 +61,12 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
@if (tenureBand; as band) {
<mat-hint>{{ band.label }} policy: {{ band.min }}–{{ band.max }} months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
@@ -75,22 +75,31 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
@if (tenureBand; as band) {
<mat-hint>Between {{ band.min }} and {{ band.max }}</mat-hint>
}
@if (tenureBand; as band) {
<mat-error
>Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months</mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
@if (tenureBand; as band) {
<mat-hint>
{{ band.label }} policy: max {{ band.max }} months
@if (band.label === 'FD') {
(also capped at deposit maturity)
}
</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
@if (tenureBand; as band) {
Must be between <strong>{{ band.min }} and {{ band.max }}</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
@@ -110,21 +110,28 @@ export class LoanProductAdvanceLimitTenureStepComponent {
return this.loanProductAdvanceLimitTenureForm.getRawValue();
}
/** True when the FD 12–15 month cap is in force (drives the hints/errors in the template). */
get isFdTenureLocked(): boolean {
return this._productSubType === 'fd';
/** 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 }> = {
fd: { label: 'FD', min: 12, max: 15, defaultTenure: 12 },
gold: { label: 'Gold', min: 1, max: 12, defaultTenure: 12 },
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. */
get tenureBand(): { label: string; min: number; max: number; defaultTenure: number } | null {
return this.tenureBands[this._productSubType] ?? null;
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
return this.tenureBand?.min ?? 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
return this.tenureBand?.max ?? null;
}
/**
* FD advances are bank-policy capped to 12–15 months; every other sub type stays open (min 1).
* FD (12–15 months) and Gold (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 —
* match the selected product.
*/
@@ -136,10 +143,11 @@ export class LoanProductAdvanceLimitTenureStepComponent {
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const band = this.tenureBand;
if (band) {
const bounds = [
Validators.min(12),
Validators.max(15)
Validators.min(band.min),
Validators.max(band.max)
];
min.setValidators([
Validators.required,
@@ -150,9 +158,9 @@ export class LoanProductAdvanceLimitTenureStepComponent {
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
min.setValue(band.min, { emitEvent: false });
def.setValue(band.defaultTenure, { emitEvent: false });
max.setValue(band.max, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
@@ -42,10 +42,6 @@
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@@ -66,10 +62,6 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
@@ -62,11 +62,9 @@ export class LoanProductChequeBookStepComponent {
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
@@ -105,6 +105,29 @@ export class LoanProductDetailsStepComponent implements OnInit {
startDate: this.loanProductsTemplate.startDate ? new Date(this.loanProductsTemplate.startDate) : new Date(),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
});
// A saved Advances / Loans product comes back with its type in the extension record. Show it filled and
// lock it: changing the type would change every other tab.
const ext = this.loanProductsTemplate?.advanceExtensions;
if (this.isExistingProduct && ext?.productTypeGroup) {
this.loanProductDetailsForm.patchValue({
productTypeGroup: ext.productTypeGroup,
productTypeSubGroup: ext.productTypeSubGroup,
glAccount: ext.glAccount ?? '',
loanProductCategory: ext.loanProductCategory ?? this.loanProductDetailsForm.value.loanProductCategory,
prioritySector: ext.prioritySector ?? this.loanProductDetailsForm.value.prioritySector,
cibilAccountType: ext.cibilAccountType ?? this.loanProductDetailsForm.value.cibilAccountType,
kfsEnabled: this.yesNo(ext.kfsEnabled, this.loanProductDetailsForm.value.kfsEnabled),
aprEnabled: this.yesNo(ext.aprEnabled, this.loanProductDetailsForm.value.aprEnabled)
});
this.loanProductDetailsForm.get('productTypeGroup').disable({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').disable({ emitEvent: false });
}
}
/** A saved YES / NO choice comes back as true / false. */
private yesNo(saved: any, fallback: any): any {
return typeof saved === 'boolean' ? (saved ? 'YES' : 'NO') : (saved ?? fallback);
}
/** True when editing a saved product; see ngOnInit. */
@@ -225,11 +248,77 @@ export class LoanProductDetailsStepComponent implements OnInit {
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
this.applyCategoryForSubType(subGroupId);
});
// The category sets the Priority Sector and CIBIL defaults. A change the officer makes here reaches this
// subscription; the sub type's own default (above) calls applyCategoryDefaults directly.
this.loanProductDetailsForm.get('loanProductCategory')?.valueChanges.subscribe((category: string) => {
this.applyCategoryDefaults(category);
});
}
/** Sub type -> the category that normally fits it. Sub types not listed leave the category as it is. */
private readonly categoryBySubType: Record<string, string> = {
fd: 'advances_deposits',
rd: 'advances_deposits',
dds: 'advances_deposits',
lic: 'advances_deposits',
gold: 'gold_loan',
warehouse: 'agriculture',
// Loans A/C (EMI)
term: 'other',
personal: 'personal',
housing: 'housing',
vehicle: 'personal',
education: 'personal',
business: 'msme',
gold_emi: 'gold_loan'
};
/**
* Category -> Priority Sector and CIBIL account type that normally go with it (RBI sector classification).
* Defaults only: the officer can still pick another value for either.
*/
private readonly defaultsByCategory: Record<string, { prioritySector: string; cibilAccountType: string }> = {
agriculture: { prioritySector: 'yes', cibilAccountType: 'other' },
msme: { prioritySector: 'yes', cibilAccountType: 'business_loan' },
personal: { prioritySector: 'no', cibilAccountType: 'other' },
housing: { prioritySector: 'no', cibilAccountType: 'other' },
advances_deposits: { prioritySector: 'no', cibilAccountType: 'loan_bank_deposits' },
gold_loan: { prioritySector: 'no', cibilAccountType: 'gold_loan' },
other: { prioritySector: 'no', cibilAccountType: 'other' }
};
/** Picking a sub type sets its usual category (unless the officer chose one), and the category's defaults follow. */
private applyCategoryForSubType(subGroupId: string): void {
const category = this.categoryBySubType[subGroupId];
if (!category) {
return;
}
const control = this.loanProductDetailsForm.get('loanProductCategory');
if (control && !control.dirty) {
control.setValue(category, { emitEvent: false });
this.applyCategoryDefaults(category);
}
}
/** Sets Priority Sector and CIBIL type for a category, leaving alone any the officer has already chosen. */
private applyCategoryDefaults(category: string): void {
const fit = this.defaultsByCategory[category];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductDetailsForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
get loanProductDetails() {
const loanProductDetailsFormData = this.loanProductDetailsForm.value;
const loanProductDetailsFormData = this.loanProductDetailsForm.getRawValue();
const prevStartDate: Date = this.loanProductDetailsForm.value.startDate;
const prevCloseDate: Date = this.loanProductDetailsForm.value.closeDate;
const dateFormat = this.settingsService.dateFormat;
@@ -0,0 +1,60 @@
<!--
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/.
-->
<form [formGroup]="loanProductEligibilityDocsForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-check"></i>
<h3>Borrower Eligibility</h3>
</div>
<div class="ed-body">
<div class="ed-rules">
<label class="ed-rule">
<mat-checkbox formControlName="memberRequired"></mat-checkbox>
<span class="ed-rule__text">
<strong>Borrower must be a member</strong>
<small>Only members of the bank can take this product</small>
</span>
</label>
<label class="ed-rule">
<mat-checkbox formControlName="guarantorMustBeMember"></mat-checkbox>
<span class="ed-rule__text">
<strong>Guarantors must be members</strong>
<small>Applies when the product asks for guarantors</small>
</span>
</label>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-file-lines"></i>
<h3>Required Documents</h3>
<span class="ed-count">{{ selectedCount }} of {{ documentOptions.length }} selected</span>
</div>
<div class="ed-body">
@for (group of documentGroups; track group.title) {
<div class="ed-group">
<div class="ed-group__title"><i class="fas {{ group.icon }}"></i>{{ group.title }}</div>
<div class="ed-docs">
@for (doc of documentsOf(group); track doc.id) {
<label class="ed-doc" [class.ed-doc--on]="isSelected(doc.id)">
<mat-checkbox
[checked]="isSelected(doc.id)"
(change)="toggleDocument(doc.id, $event.checked)"
></mat-checkbox>
<span>{{ doc.label }}</span>
</label>
}
</div>
</div>
}
</div>
</div>
</form>
@@ -0,0 +1,163 @@
/**
* 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 { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Borrower eligibility and document checklist, common to Advances A/C and Loans A/C. Age, CIBIL, income and
* FOIR already live on the Loans amount/tenure step, so only the rules both categories share are held here.
* Sent as satellite params (LoanProductAdvanceExtService.ADVANCE_PARAMETERS).
*/
@Component({
selector: 'mifosx-loan-product-eligibility-docs-step',
templateUrl: './loan-product-eligibility-docs-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductEligibilityDocsStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeGroup: UntypedFormControl;
loanProductEligibilityDocsForm: UntypedFormGroup;
readonly documentOptions = [
{ id: 'KYC', label: 'KYC (ID proof)' },
{ id: 'ADDRESS_PROOF', label: 'Address proof' },
{ id: 'INCOME_PROOF', label: 'Income proof / salary certificate' },
{ id: 'ITR', label: 'Income-tax returns (3 years)' },
{ id: 'DEMAND_PROMISSORY_NOTE', label: 'Demand promissory note' },
{ id: 'LOAN_AGREEMENT', label: 'Loan agreement' },
{ id: 'GUARANTEE_DEED', label: 'Guarantor agreement' },
{ id: 'SALARY_DEDUCTION_AUTHORITY', label: 'Employer salary-deduction authority' },
{ id: 'LETTER_OF_LIEN', label: 'Letter of lien and set-off' },
{ id: 'LETTER_OF_CONTINUITY', label: 'Letter of continuity' },
{ id: 'HYPOTHECATION_AGREEMENT', label: 'Hypothecation agreement' },
{ id: 'TITLE_DEED', label: 'Title deed / registry' },
{ id: 'VALUATION_REPORT', label: 'Valuation report' },
{ id: 'STOCK_STATEMENT', label: 'Stock statement' },
{ id: 'INSURANCE_POLICY', label: 'Insurance policy (joint name)' },
{ id: 'QUOTATION', label: 'Dealer quotation / estimate' }
];
/** The checklist grouped the way an officer thinks about the papers. Every document sits in exactly one group. */
readonly documentGroups: { title: string; icon: string; ids: string[] }[] = [
{ title: 'Identity and address', icon: 'fa-id-card', ids: [
'KYC',
'ADDRESS_PROOF'
] },
{ title: 'Income', icon: 'fa-money-bill-wave', ids: [
'INCOME_PROOF',
'ITR',
'SALARY_DEDUCTION_AUTHORITY'
] },
{
title: 'Agreements',
icon: 'fa-file-signature',
ids: [
'DEMAND_PROMISSORY_NOTE',
'LOAN_AGREEMENT',
'GUARANTEE_DEED',
'HYPOTHECATION_AGREEMENT'
]
},
{ title: 'Lien and continuity', icon: 'fa-lock', ids: [
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
] },
{
title: 'Security papers',
icon: 'fa-shield-halved',
ids: [
'TITLE_DEED',
'VALUATION_REPORT',
'STOCK_STATEMENT',
'INSURANCE_POLICY',
'QUOTATION'
]
}
];
documentsOf(group: { ids: string[] }): { id: string; label: string }[] {
return this.documentOptions.filter((doc) => group.ids.includes(doc.id));
}
get selectedCount(): number {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).length;
}
private readonly defaultDocuments: Record<string, string[]> = {
advances: [
'KYC',
'DEMAND_PROMISSORY_NOTE',
'LETTER_OF_LIEN',
'LETTER_OF_CONTINUITY'
],
loans: [
'KYC',
'ADDRESS_PROOF',
'INCOME_PROOF',
'LOAN_AGREEMENT',
'GUARANTEE_DEED'
]
};
constructor() {
this.loanProductEligibilityDocsForm = this.formBuilder.group({
memberRequired: [true],
guarantorMustBeMember: [true],
requiredDocuments: [[]]
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyDefaultDocuments(this.productTypeGroup.value);
this.productTypeGroup.valueChanges.subscribe((group: string) => this.applyDefaultDocuments(group));
}
}
/** Pre-ticks the usual checklist for the category until the user edits it. */
private applyDefaultDocuments(group: string): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
if (control && !control.dirty && this.defaultDocuments[group]) {
control.setValue([...this.defaultDocuments[group]]);
}
}
isSelected(id: string): boolean {
return (this.loanProductEligibilityDocsForm.get('requiredDocuments')?.value ?? []).includes(id);
}
toggleDocument(id: string, checked: boolean): void {
const control = this.loanProductEligibilityDocsForm.get('requiredDocuments');
const current: string[] = control?.value ?? [];
control?.setValue(
checked
? [
...new Set([
...current,
id
])
]
: current.filter((d) => d !== id)
);
control?.markAsDirty();
}
get loanProductEligibilityDocs() {
const value = this.loanProductEligibilityDocsForm.getRawValue();
return { ...value, requiredDocuments: (value.requiredDocuments ?? []).join(',') };
}
}
@@ -44,18 +44,38 @@
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" min="1" required />
<mat-error>Minimum Tenure is <strong>required</strong></mat-error>
<input matInput type="number" formControlName="minTenure" [min]="band?.minTenure ?? 1" required />
@if (band; as b) {
<mat-hint>{{ b.label }}: {{ b.minTenure }}–{{ b.maxTenure }} months</mat-hint>
}
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" min="1" />
<input matInput type="number" formControlName="defaultTenure" [min]="band?.minTenure ?? 1" />
@if (band; as b) {
<mat-error
>Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months</mat-error
>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" min="1" required />
<input matInput type="number" formControlName="maxTenure" [min]="band?.minTenure ?? 1" required />
<mat-hint>Age at maturity cap comes from Borrower Eligibility below</mat-hint>
<mat-error>Maximum Tenure is <strong>required</strong></mat-error>
<mat-error>
@if (band; as b) {
Must be between <strong>{{ b.minTenure }} and {{ b.maxTenure }}</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
</div>
</div>
@@ -90,6 +110,17 @@
<mat-label>Maximum Loans per Customer</mat-label>
<input matInput type="number" formControlName="maxLoansPerCustomer" min="1" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Income Multiple (× net monthly salary)</mat-label>
<input matInput type="number" formControlName="incomeMultiple" min="0" step="0.5" />
<mat-hint>Loan up to this many times the net salary. Policy: personal loan 10×</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="memberRequired">Applicant must be a member of the bank</mat-checkbox>
<mat-checkbox formControlName="guarantorMustBeMember">Guarantors must be members of the bank</mat-checkbox>
</div>
</div>
</div>
@@ -6,12 +6,70 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Tenure band (months) and borrower age limits for a Loans A/C sub type, taken from the bank's loan policy: short term
* up to 15 months, medium 15 to 84, long above 84, and never more than 120. Housing runs 7 to 10 years, education is
* repaid in 84 equal instalments, vehicle 3 to 7 years, personal 3 to 5 years.
*/
interface LoanBand {
label: string;
minTenure: number;
defaultTenure: number;
maxTenure: number;
minAge: number;
maxAgeAtMaturity?: number;
/** Loan = this many times the net monthly salary (policy: personal loan 10x). */
incomeMultiple?: number;
/** The guarantors must themselves be members of the bank (policy: personal loan). */
guarantorMustBeMember?: boolean;
}
const LOAN_BANDS: Record<string, LoanBand> = {
personal: {
label: 'Personal Loan',
minTenure: 6,
defaultTenure: 36,
maxTenure: 60,
minAge: 21,
maxAgeAtMaturity: 60,
incomeMultiple: 10,
guarantorMustBeMember: true
},
housing: {
label: 'Housing Loan',
minTenure: 12,
defaultTenure: 84,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 70
},
vehicle: { label: 'Vehicle Loan', minTenure: 12, defaultTenure: 60, maxTenure: 84, minAge: 21, maxAgeAtMaturity: 65 },
education: { label: 'Education Loan', minTenure: 12, defaultTenure: 84, maxTenure: 84, minAge: 18 },
business: {
label: 'Business / MSME Loan',
minTenure: 12,
defaultTenure: 60,
maxTenure: 120,
minAge: 21,
maxAgeAtMaturity: 65
},
gold_emi: {
label: 'Gold EMI Loan',
minTenure: 6,
defaultTenure: 12,
maxTenure: 36,
minAge: 21,
maxAgeAtMaturity: 70
},
term: { label: 'Term Loan', minTenure: 12, defaultTenure: 60, maxTenure: 120, minAge: 21, maxAgeAtMaturity: 65 }
};
/**
* Amount & Tenure step for a Loans A/C (EMI) product (step 4): fixed sanctioned amount, tenure,
* borrower eligibility and disbursement. Amount, tenure and tranches feed Fineract's core product
@@ -33,6 +91,27 @@ export class LoanProductLoanAmountTenureStepComponent {
loanProductLoanAmountTenureForm: UntypedFormGroup;
private _productSubType = '';
/** The sub type picked on the Details step: it sets the tenure band the three tenure fields must stay inside. */
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyBand();
}
get productSubType(): string {
return this._productSubType;
}
/** The band in force for the chosen sub type, or null for a sub type without one. */
get band(): LoanBand | null {
return LOAN_BANDS[this._productSubType] ?? null;
}
readonly disbursementModeOptions = [
{ id: 'transfer_dd_vendor', label: 'Transfer to A/C, DD, Direct to vendor' },
{ id: 'transfer', label: 'Transfer to A/C only' },
@@ -85,6 +164,13 @@ export class LoanProductLoanAmountTenureStepComponent {
minMonthlyIncome: [''],
maxFoir: [50],
maxLoansPerCustomer: [1],
// Policy: loans go to members, so the applicant must be a member; a personal loan also needs member guarantors.
memberRequired: [true],
guarantorMustBeMember: [false],
incomeMultiple: [
'',
Validators.min(0)
],
disbursementType: ['single'],
maxTranches: [
2,
@@ -96,6 +182,68 @@ export class LoanProductLoanAmountTenureStepComponent {
});
}
/** Sets the tenure limits and the age defaults for the sub type; a value the officer has typed is left alone. */
private applyBand(): void {
const form = this.loanProductLoanAmountTenureForm;
const band = this.band;
if (!form || !band) {
return;
}
const tenureControls: [string, number][] = [
[
'minTenure',
band.minTenure
],
[
'defaultTenure',
band.defaultTenure
],
[
'maxTenure',
band.maxTenure
]
];
const bounds = [
Validators.min(band.minTenure),
Validators.max(band.maxTenure)
];
tenureControls.forEach(
([
name,
value
]) => {
const control = form.get(name);
control.setValidators(
name === 'defaultTenure' ? bounds : [
Validators.required,
...bounds
]
);
if (!control.dirty) {
control.setValue(value, { emitEvent: false });
}
control.updateValueAndValidity({ emitEvent: false });
}
);
const incomeMultiple = form.get('incomeMultiple');
if (!incomeMultiple.dirty) {
incomeMultiple.setValue(band.incomeMultiple ?? '', { emitEvent: false });
}
const guarantorMember = form.get('guarantorMustBeMember');
if (!guarantorMember.dirty) {
guarantorMember.setValue(band.guarantorMustBeMember ?? false, { emitEvent: false });
}
const minAge = form.get('minAge');
if (!minAge.dirty) {
minAge.setValue(band.minAge, { emitEvent: false });
}
const maxAge = form.get('maxAgeAtMaturity');
// A sub type without its own limit goes back to the general 70, not the previous sub type's value.
if (!maxAge.dirty) {
maxAge.setValue(band.maxAgeAtMaturity ?? 70, { emitEvent: false });
}
}
get isMultiTranche(): boolean {
return this.loanProductLoanAmountTenureForm.get('disbursementType').value === 'multiple';
}
@@ -63,8 +63,16 @@
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · floating rate (%)</mat-label>
<input matInput type="number" formControlName="foreclosureFloatingIndividual" min="0" max="100" step="0.01" />
<mat-hint>Nil as per RBI</mat-hint>
<input
matInput
type="number"
formControlName="foreclosureFloatingIndividual"
min="0"
max="0"
step="0.01"
readonly
/>
<mat-hint>Nil as per RBI. Cannot be changed</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Individual · fixed rate (%)</mat-label>
@@ -132,7 +140,6 @@
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="releaseCollateralOnClosure">Release collateral and lien on closure</mat-checkbox>
<mat-checkbox formControlName="topUpAllowed">Top-up loan on the same account</mat-checkbox>
<mat-checkbox formControlName="balanceTransferOutAllowed">Allow balance transfer (takeover) out</mat-checkbox>
</div>
@@ -77,9 +77,13 @@ export class LoanProductLoanPrepaymentStepComponent {
allowedOn: ['emi_date'],
partPaymentsPerYear: ['unlimited'],
partPrepaymentMode: ['reduce_tenure'],
// RBI: no foreclosure / pre-payment charge on a floating-rate loan to an individual, so this stays at nil.
foreclosureFloatingIndividual: [
0,
percent
[
Validators.min(0),
Validators.max(0)
]
],
foreclosureFixedIndividual: [
2,
@@ -105,7 +109,6 @@ export class LoanProductLoanPrepaymentStepComponent {
autoCloseOnZeroBalance: ['YES'],
nocLetter: ['auto'],
restructuring: ['approval'],
releaseCollateralOnClosure: [true],
topUpAllowed: [false],
balanceTransferOutAllowed: [true]
});
@@ -49,6 +49,7 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Days to First Repayment</mat-label>
<input matInput type="number" formControlName="minDaysToFirstRepayment" min="0" />
<mat-hint>Policy: instalments start from the 2nd month after the loan month</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>EMI Due Day</mat-label>
@@ -69,6 +70,7 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Moratorium / Grace (months)</mat-label>
<input matInput type="number" formControlName="moratoriumMonths" min="0" />
<mat-hint>Interest and EMI fall due only after the moratorium ends (RBI)</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest during Moratorium</mat-label>
@@ -98,14 +100,16 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isAutoDebit) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Linked Account Type</mat-label>
<mat-select formControlName="linkedAccountType">
@for (opt of linkedAccountOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PDC Collection</mat-label>
<mat-select formControlName="pdcCollection">
@@ -115,11 +119,13 @@
</mat-select>
</mat-form-field>
</div>
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
@if (isAutoDebit) {
<div class="adv-subsection">
<mat-checkbox formControlName="mandateRequired">
Mandate registration is required before the first disbursement
</mat-checkbox>
</div>
}
<div style="padding: 0 24px 20px">
<div class="adv-info">
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is
@@ -6,7 +6,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
@@ -28,17 +28,17 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
export class LoanProductLoanRepaymentStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
// Bullet / Step-up / Balloon live on the Advances A/C "Limit & Tenure" step instead — a Loans A/C
// (EMI) product always repays on a fixed instalment schedule, so only these three belong here.
readonly repaymentTypeOptions = [
{ id: 'emi', label: 'EMI', hint: 'Reducing balance, equal instalments' },
{ id: 'equal_principal', label: 'Equal principal', hint: 'Falling instalments' },
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' },
{ id: 'bullet', label: 'Bullet', hint: 'Pay everything at maturity' },
{ id: 'step', label: 'Step-up / Balloon', hint: 'Custom schedule' }
{ id: 'flat', label: 'Flat rate', hint: 'Interest on original amount' }
];
/** `fineractId` is Fineract's repaymentFrequencyType (0 days, 1 weeks, 2 months). */
@@ -50,7 +50,7 @@ export class LoanProductLoanRepaymentStepComponent {
readonly emiDueDayOptions = [
{ id: 'disbursement_day', label: 'Same day as disbursement' },
{ id: 'fixed_day', label: 'Fixed day of month (5th)' }
{ id: 'fixed_day', label: 'Fixed day of the month (day set on each account)' }
];
readonly holidayRuleOptions = [
@@ -114,16 +114,52 @@ export class LoanProductLoanRepaymentStepComponent {
],
moratoriumInterest: ['pre_emi'],
variableInstallments: [false],
repaymentMode: ['nach'],
// Loan policy: cheques for the instalments are taken together in advance (quarterly), so cheques are the default.
repaymentMode: ['pdc'],
linkedAccountType: ['savings_current'],
pdcCollection: ['mandatory'],
mandateRequired: [true],
mandateRequired: [false],
appropriationOrder: ['charges_penal_interest_principal'],
advanceEmi: ['adjust_next']
});
}
ngOnInit(): void {
// The mode decides how the EMI is collected, so it sets the cheque and mandate defaults that go with it.
this.loanProductLoanRepaymentForm
.get('repaymentMode')
.valueChanges.subscribe((mode: string) => this.applyMode(mode));
}
private applyMode(mode: string): void {
const defaults: Record<string, { pdcCollection: string; mandateRequired: boolean }> = {
nach: { pdcCollection: 'optional', mandateRequired: true },
pdc: { pdcCollection: 'mandatory', mandateRequired: false },
cash: { pdcCollection: 'none', mandateRequired: false }
};
const fit = defaults[mode];
if (!fit) {
return;
}
(Object.keys(fit) as (keyof typeof fit)[]).forEach((name) => {
const control = this.loanProductLoanRepaymentForm.get(name);
if (control && !control.dirty) {
control.setValue(fit[name], { emitEvent: false });
}
});
}
/** Auto-debit needs a linked account and a registered mandate; the other modes do not. */
get isAutoDebit(): boolean {
return this.loanProductLoanRepaymentForm.get('repaymentMode').value === 'nach';
}
get loanProductLoanRepayment() {
return this.loanProductLoanRepaymentForm.getRawValue();
const value = this.loanProductLoanRepaymentForm.getRawValue();
// A mandate only exists for auto-debit; never save it as required for cheques or cash.
if (!this.isAutoDebit) {
value.mandateRequired = false;
}
return value;
}
}
@@ -28,6 +28,9 @@
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
@if (marginHint) {
<mat-hint>{{ marginHint }}</mat-hint>
}
<mat-error>Margin must be between <strong>0 and 100</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
@@ -38,40 +41,6 @@
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-table"></i>
<h3>Collateral Rules by Security Type</h3>
<span class="adv-section__hint">Which security this product may accept</span>
</div>
<table class="adv-table">
<thead>
<tr>
<th>Security</th>
<th>Allowed</th>
<th>Max LTV %</th>
<th>Valuation</th>
<th>Insurance</th>
<th>Charge / lien</th>
</tr>
</thead>
<tbody>
@for (rule of collateralRules; track rule.security) {
<tr>
<td>{{ rule.security }}</td>
<td [class.adv-table__yes]="rule.allowed" [class.adv-table__no]="!rule.allowed">
{{ rule.allowed ? 'Yes' : 'No' }}
</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.ltv }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.valuation }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.insurance }}</td>
<td [class.adv-table__no]="!rule.allowed">{{ rule.charge }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-user-plus"></i>
@@ -90,14 +59,16 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (isSecured) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Third-party Security</mat-label>
<mat-select formControlName="thirdPartySecurity">
@for (opt of thirdPartyOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
@@ -107,18 +78,15 @@
<h3>Conditions</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="legalOpinionRequired">
Legal opinion / title search mandatory before sanction
</mat-checkbox>
<mat-checkbox formControlName="propertyInsuranceRequired">
Property insurance mandatory for the whole tenure
</mat-checkbox>
<mat-checkbox formControlName="registerCharge">Register charge (CERSAI) before disbursement</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">Release security automatically on closure</mat-checkbox>
<mat-checkbox formControlName="allowMultipleSecurities">Allow multiple securities for one loan</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">Allow third-party deposit as security</mat-checkbox>
</div>
@if (shownConditions.length > 0) {
<div class="adv-checks">
@for (rule of shownConditions; track rule.control) {
<mat-checkbox [formControlName]="rule.control">{{ rule.label }}</mat-checkbox>
}
</div>
} @else {
<div class="adv-info">This loan is unsecured, so it carries no security conditions.</div>
}
</div>
<div style="padding: 0 24px 20px">
<div class="adv-info">
@@ -6,16 +6,157 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
interface SecurityProfile {
securityType: string;
securityMandatory: 'yes' | 'no';
margin: number;
guarantorsRequired: number;
coApplicant: string;
thirdPartySecurity: string;
}
/** One condition a sub type's security carries: the checkbox it controls, its wording, and whether it starts ticked. */
interface ConditionRule {
control: string;
label: string;
defaultOn: boolean;
}
/** Every condition checkbox the form holds. One not listed for a sub type does not apply to it and is saved as off. */
const CONDITION_CONTROLS = [
'legalOpinionRequired',
'propertyInsuranceRequired',
'registerCharge',
'autoReleaseOnClosure',
'allowMultipleSecurities',
'allowThirdPartyDeposit'
];
/** Immovable property (Housing, Business, Term): title check, property bima, CERSAI charge. */
const PROPERTY_CONDITIONS: ConditionRule[] = [
{ control: 'legalOpinionRequired', label: 'Legal opinion / title search mandatory before sanction', defaultOn: true },
{ control: 'propertyInsuranceRequired', label: 'Property insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Register charge (CERSAI) before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the mortgage automatically on closure', defaultOn: true },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false }
];
/** Vehicle: the vehicle is hypothecated, insured, and the lien is noted on the RC book. */
const VEHICLE_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Vehicle insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'registerCharge', label: 'Note the hypothecation on the RC book before disbursement', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Release the hypothecation automatically on closure', defaultOn: true }
];
/** Gold EMI: the gold is pledged and insured, and handed back on closure. No registered charge. */
const GOLD_CONDITIONS: ConditionRule[] = [
{ control: 'propertyInsuranceRequired', label: 'Gold insurance mandatory for the whole tenure', defaultOn: true },
{ control: 'autoReleaseOnClosure', label: 'Return the gold automatically on closure', defaultOn: true }
];
/** Wording used when a sub type has no list of its own but the officer made security mandatory. */
const GENERIC_CONDITIONS: ConditionRule[] = [
{
control: 'legalOpinionRequired',
label: 'Legal opinion / title search mandatory before sanction',
defaultOn: false
},
{
control: 'propertyInsuranceRequired',
label: 'Security insurance mandatory for the whole tenure',
defaultOn: false
},
{ control: 'registerCharge', label: 'Register the charge on the security before disbursement', defaultOn: false },
{ control: 'autoReleaseOnClosure', label: 'Release the security automatically on closure', defaultOn: false },
{ control: 'allowMultipleSecurities', label: 'Allow multiple securities for one loan', defaultOn: false },
{ control: 'allowThirdPartyDeposit', label: 'Allow third-party deposit as security', defaultOn: false }
];
/** Personal and Education are unsecured, so they carry no security conditions unless security is made mandatory. */
const CONDITIONS_BY_SUB_TYPE: Record<string, ConditionRule[]> = {
housing: PROPERTY_CONDITIONS,
business: PROPERTY_CONDITIONS,
term: PROPERTY_CONDITIONS,
vehicle: VEHICLE_CONDITIONS,
gold_emi: GOLD_CONDITIONS,
personal: [],
education: PROPERTY_CONDITIONS
};
/** Sensible defaults by Loans A/C sub type — the officer can still override every field. */
const SECURITY_PROFILES: Record<string, SecurityProfile> = {
// Policy: a personal loan takes two guarantors who are bank members.
personal: {
securityType: 'None (unsecured)',
securityMandatory: 'no',
margin: 0,
guarantorsRequired: 2,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
// Policy: 90% of the expense is lent (the student bears 10%), against a house / plot / NSC worth double the loan,
// with the parent or guardian applying jointly.
education: {
securityType: 'Property / NSC (double the loan value)',
securityMandatory: 'yes',
margin: 10,
guarantorsRequired: 1,
coApplicant: 'always',
thirdPartySecurity: 'consent'
},
housing: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
vehicle: {
securityType: 'Vehicle (hypothecation)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
business: {
securityType: 'Immovable property / business assets',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
gold_emi: {
securityType: 'Gold ornaments (pledge)',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
term: {
securityType: 'Immovable property (mortgage)',
securityMandatory: 'yes',
margin: 20,
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
}
};
/**
* Security & Collateral step for a Loans A/C (EMI) product (step 3).
*
* Unlike an Advance, the security only decides eligibility at sanction — it does not set a running
* limit. UI-only for now: not sent in the create payload.
* limit. Defaults adapt to the chosen sub type (e.g. Personal/Education default to unsecured,
* Housing/Vehicle/Gold default to their usual collateral) but every field stays editable.
*/
@Component({
selector: 'mifosx-loan-product-loan-security-step',
@@ -26,11 +167,46 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox
]
})
export class LoanProductLoanSecurityStepComponent {
export class LoanProductLoanSecurityStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() productTypeSubGroup: UntypedFormControl;
loanProductLoanSecurityForm: UntypedFormGroup;
/** What the bank's loan policy says about the margin for a sub type, shown under the Margin field. */
private readonly marginHints: Record<string, string> = {
housing: 'Policy: house construction 25%, flat / house purchase 20%, repair or renovation 10%',
vehicle: 'Policy: new vehicle 20–25%, old vehicle 50%',
education: 'Policy: the student bears 10% of the expense',
business: 'Policy: machinery 25% (old machinery 40–50%)',
personal: 'Unsecured: no margin'
};
get marginHint(): string {
return this.marginHints[this.productTypeSubGroup?.value] ?? '';
}
/** The conditions that apply to the chosen sub type; the template draws one checkbox for each. */
applicableConditions: ConditionRule[] = [];
/**
* The conditions drawn on screen: nothing while the loan is unsecured; the sub type's own list when it has one; and
* every condition (all off) when the officer makes security mandatory on a sub type that has no list of its own,
* so the officer can always set them.
*/
get shownConditions(): ConditionRule[] {
if (!this.isSecured) {
return [];
}
return this.applicableConditions.length > 0 ? this.applicableConditions : GENERIC_CONDITIONS;
}
/** True when sanction needs security; an unsecured product shows no security conditions. */
get isSecured(): boolean {
return this.loanProductLoanSecurityForm.get('securityMandatory').value === 'yes';
}
readonly securityMandatoryOptions = [
{ id: 'yes', label: 'Yes — no sanction without security' },
{ id: 'no', label: 'No — unsecured' }
@@ -47,44 +223,6 @@ export class LoanProductLoanSecurityStepComponent {
{ id: 'no', label: 'Not allowed' }
];
/** Reference table: which security a loan on this product may accept. */
readonly collateralRules = [
{
security: 'Residential property',
allowed: true,
ltv: '80',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Commercial property',
allowed: true,
ltv: '70',
valuation: 'Empanelled valuer',
insurance: 'Mandatory',
charge: 'Mortgage + CERSAI'
},
{
security: 'Fixed / Recurring Deposit',
allowed: true,
ltv: '90',
valuation: 'Not needed',
insurance: 'Not needed',
charge: 'Lien marked'
},
{
security: 'LIC policy',
allowed: true,
ltv: '90',
valuation: 'Surrender value',
insurance: 'Not needed',
charge: 'Assignment'
},
{ security: 'Vehicle', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' },
{ security: 'Gold ornaments', allowed: false, ltv: '—', valuation: '—', insurance: '—', charge: '—' }
];
constructor() {
this.loanProductLoanSecurityForm = this.formBuilder.group({
securityType: ['Immovable property (mortgage)'],
@@ -112,6 +250,49 @@ export class LoanProductLoanSecurityStepComponent {
});
}
ngOnInit(): void {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep the security defaults in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
const profile = SECURITY_PROFILES[subGroup];
if (!profile) {
return;
}
this.loanProductLoanSecurityForm.get('securityType').setValue(profile.securityType, { emitEvent: false });
this.loanProductLoanSecurityForm.get('securityMandatory').setValue(profile.securityMandatory, { emitEvent: false });
if (!this.loanProductLoanSecurityForm.get('margin').dirty) {
this.loanProductLoanSecurityForm.get('margin').setValue(profile.margin, { emitEvent: false });
}
if (!this.loanProductLoanSecurityForm.get('guarantorsRequired').dirty) {
this.loanProductLoanSecurityForm
.get('guarantorsRequired')
.setValue(profile.guarantorsRequired, { emitEvent: false });
}
([
'coApplicant',
'thirdPartySecurity'
] as const).forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
control.setValue(profile[name], { emitEvent: false });
}
});
// Only the conditions that fit this kind of security apply; the rest are off and not shown.
const rules = CONDITIONS_BY_SUB_TYPE[subGroup] ?? [];
this.applicableConditions = rules;
CONDITION_CONTROLS.forEach((name) => {
const control = this.loanProductLoanSecurityForm.get(name);
if (!control.dirty) {
const rule = rules.find((entry) => entry.control === name);
control.setValue(rule ? rule.defaultOn : false, { emitEvent: false });
}
});
}
/** Maximum LTV is derived from the margin (100 − margin) and shown read-only. */
get ltv(): number {
const margin = Number(this.loanProductLoanSecurityForm.get('margin').value);
@@ -119,8 +300,16 @@ export class LoanProductLoanSecurityStepComponent {
}
get loanProductLoanSecurity() {
const value = this.loanProductLoanSecurityForm.getRawValue();
// A condition that is not shown (unsecured, or not part of this security) is never saved as on.
const shown = new Set(this.shownConditions.map((rule) => rule.control));
CONDITION_CONTROLS.forEach((name) => {
if (!shown.has(name)) {
value[name] = false;
}
});
return {
...this.loanProductLoanSecurityForm.getRawValue(),
...value,
ltv: this.ltv
};
}
@@ -7,13 +7,14 @@
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<!-- 1. PENAL -->
<div class="adv-section pn-card pn-card--penal">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
@@ -21,11 +22,11 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
@@ -33,7 +34,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
@@ -45,13 +46,14 @@
</div>
</div>
<div class="adv-section">
<!-- 2. SMA -->
<div class="adv-section pn-card pn-card--sma">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
<i class="fas fa-bell"></i>
<h3>SMA (Special Mention Account)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@@ -61,11 +63,38 @@
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>SMA Counting Starts After (months)</mat-label>
<input matInput type="number" formControlName="smaStartAfterMonths" min="0" required />
<mat-hint>
{{ isAdvanceAccount ? 'Advances: from disbursement (default 9)' : 'Loans: first EMI due (default 1)' }}
</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="pn-stages">
<span class="pn-stage pn-stage--0">SMA-0 · 1–30 days</span>
<span class="pn-stage pn-stage--1">SMA-1 · 31–60 days</span>
<span class="pn-stage pn-stage--2">SMA-2 · 61–90 days</span>
</div>
</div>
<!-- 3. NPA -->
<div class="adv-section pn-card pn-card--npa">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>NPA (Non-Performing Asset)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
@@ -73,7 +102,7 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
@@ -81,26 +110,45 @@
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
@if (isRevolvingSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>NPA Out-of-Order Basis (CC/OD)</mat-label>
<mat-select formControlName="npaOutOfOrderBasis">
@for (opt of npaOutOfOrderBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Stock Statement Validity (days)</mat-label>
<input matInput type="number" formControlName="stockStatementValidityDays" min="0" />
<mat-hint>Older statement + drawings 90 days = NPA</mat-hint>
</mat-form-field>
}
@if (isKccSubType) {
<mat-form-field appearance="outline" class="adv-field" subscriptSizing="dynamic">
<mat-label>Crop Duration (KCC)</mat-label>
<mat-select formControlName="cropDuration">
@for (opt of cropDurationOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
@if (isDepositBacked) {
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
}
</div>
</div>
</div>
<!-- 4. ECL (SICR & LGD): hidden for now. The form controls stay in the .ts so the card can be restored. -->
</form>
@@ -6,14 +6,16 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { Component, Input, OnInit, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
* Penal charges & asset-classification (SMA / NPA / SICR / LGD) step for a loan product (Figma step 6).
* Shared by both Advances A/C and Loans A/C flows — fields are sent in the create/update payload via
* `loanProductPenalNpa`, mapped onto the core product fields and the LoanProductAdvanceExt satellite params
* in create-loan-product.component.ts.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
@@ -24,13 +26,40 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent {
export class LoanProductPenalNpaStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
@Input() productTypeGroup: UntypedFormControl;
@Input() productTypeSubGroup: UntypedFormControl;
loanProductPenalNpaForm: UntypedFormGroup;
/** True for Advances A/C (cc/overdraft/kcc style limit products); drives which SICR/LGD fields show. */
isAdvanceAccount = false;
/** The ECL card is optional and starts collapsed. */
eclOpen = false;
/** One line for the collapsed ECL card. */
get eclSummary(): string {
const v = this.loanProductPenalNpaForm.getRawValue();
const days = this.isRevolvingSubType ? v.sicrRevolvingDaysThreshold : v.sicrDaysThreshold;
return `Stage 2 after ${days} days late · loss ${v.lgdSecuredPct}% with security, ${v.lgdUnsecuredPct}% without`;
}
/** The chosen sub type (e.g. 'cc', 'overdraft', 'kcc', 'gold') within the current product type group. */
subType: string | null = null;
/** FD / RD / DDS / LIC are lent against a deposit or policy; Gold, CC and the rest are pledged or hypothecated. */
get isDepositBacked(): boolean {
return [
'fd',
'rd',
'dds',
'lic'
].includes(this.subType ?? '');
}
readonly yesNoOptions = [
'YES',
'NO'
@@ -43,7 +72,8 @@ export class LoanProductPenalNpaStepComponent {
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' },
{ id: 'crop_season', label: 'Overdue for crop season(s) (KCC)' }
];
readonly interestAfterNpaOptions = [
@@ -51,10 +81,48 @@ export class LoanProductPenalNpaStepComponent {
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
readonly sicrAssessmentBasisOptions = [
{ id: 'DPD', label: 'Days-Past-Due threshold only' },
{ id: 'RATING_DOWNGRADE', label: 'Internal rating downgrade only' },
{ id: 'PRICING_INCREASE', label: 'Risk-based repricing only' },
{ id: 'DPD_AND_RATING', label: 'DPD + rating downgrade (either triggers)' }
];
/** RBI 2026: CC/OD "out of order" basis (para on revolving facilities). */
readonly npaOutOfOrderBasisOptions = [
{ id: 'LIMIT_EXCEEDED', label: 'Outstanding continuously exceeds limit/drawing power' },
{ id: 'NO_CREDITS_90_DAYS', label: 'No credits for 90 days, or credits < interest debited' },
{ id: 'DP_NOT_RENEWED', label: 'Drawing power not reassessed/renewed within 180 days' }
];
readonly cropDurationOptions = [
{ id: 'KHARIF', label: 'Kharif (short duration)' },
{ id: 'RABI', label: 'Rabi (short duration)' },
{ id: 'LONG_DURATION', label: 'Long duration crop' }
];
readonly eligibleCollateralTypeOptions = [
{ id: 'NONE', label: 'None' },
{ id: 'GOLD', label: 'Gold / gold jewellery' },
{ id: 'COMMODITY', label: 'Commodity (warehouse receipt)' },
{ id: 'GOVERNMENT_SECURITIES', label: 'Government securities' },
{ id: 'BANK_DEPOSIT', label: 'Bank term deposit (lien-marked)' },
{ id: 'LIFE_INSURANCE', label: 'Life insurance policy (surrender value)' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
/** CC/OD are revolving facilities — RBI's "out of order" NPA basis and 60-day SICR window apply to them. */
get isRevolvingSubType(): boolean {
return this.subType === 'cc' || this.subType === 'overdraft';
}
get isKccSubType(): boolean {
return this.subType === 'kcc';
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
@@ -78,13 +146,125 @@ export class LoanProductPenalNpaStepComponent {
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
smaStartAfterMonths: [
1,
[
Validators.required,
Validators.min(0)
]
],
stockStatementValidityDays: [
90,
[Validators.min(0)]
],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true]
keepStandardIfDepositCovers: [true],
// ---- SICR (Significant Increase in Credit Risk) — RBI 2026 ECL framework ----
sicrDaysThreshold: [
30,
[Validators.min(0)]
],
sicrRevolvingDaysThreshold: [
60,
[Validators.min(0)]
],
sicrRatingDowngradeNotches: [
2,
[Validators.min(0)]
],
sicrPricingIncreaseTriggerPct: [
1.5,
[
Validators.min(0),
Validators.max(100)
]
],
sicrAssessmentBasis: ['DPD'],
sicrRebuttalAllowed: [false],
npaOutOfOrderBasis: ['LIMIT_EXCEEDED'],
cropDuration: [''],
// ---- LGD (Loss Given Default) — regulatory backstop vs bank's own model ----
useOwnLgdModel: [false],
lgdSecuredPct: [
65,
[
Validators.min(0),
Validators.max(100)
]
],
lgdUnsecuredPct: [
70,
[
Validators.min(0),
Validators.max(100)
]
],
eligibleCollateralType: ['NONE']
});
}
ngOnInit() {
if (this.productTypeGroup) {
this.applyProductTypeRules(this.productTypeGroup.value === 'advances', this.productTypeSubGroup?.value ?? null);
this.productTypeGroup.valueChanges.subscribe((group: string) => {
this.applyProductTypeRules(group === 'advances', this.productTypeSubGroup?.value ?? null);
});
}
if (this.productTypeSubGroup) {
this.productTypeSubGroup.valueChanges.subscribe((subGroup: string) => {
this.applyProductTypeRules(this.productTypeGroup?.value === 'advances', subGroup);
});
}
}
/**
* CC/OD (revolving) default to the 60-day SICR window and show the "out of order" NPA basis; KCC shows
* the crop-duration field instead. Term/EMI loans use the plain 30-day DPD-based SICR window and hide
* both revolving-only fields. Only defaults the value the first time a sub type becomes relevant, so it
* doesn't clobber a value the user already edited.
*/
private applyProductTypeRules(isAdvance: boolean, subType: string | null): void {
this.isAdvanceAccount = isAdvance;
this.subType = isAdvance ? subType : null;
if (this.isRevolvingSubType && !this.loanProductPenalNpaForm.get('sicrAssessmentBasis')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ sicrAssessmentBasis: 'DPD_AND_RATING' });
}
// The security that backs the product decides which collateral counts for ECL.
const collateralBySubType: Record<string, string> = {
fd: 'BANK_DEPOSIT',
rd: 'BANK_DEPOSIT',
dds: 'BANK_DEPOSIT',
lic: 'LIFE_INSURANCE',
gold: 'GOLD',
warehouse: 'COMMODITY',
gold_emi: 'GOLD'
};
const collateral = subType ? collateralBySubType[subType] : undefined;
if (!this.loanProductPenalNpaForm.get('eligibleCollateralType')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ eligibleCollateralType: collateral ?? 'NONE' });
}
// SMA counting starts 9 months after disbursement for Advances, 1 month for Loans (EMI).
if (!this.loanProductPenalNpaForm.get('smaStartAfterMonths')?.dirty) {
this.loanProductPenalNpaForm.patchValue({ smaStartAfterMonths: isAdvance ? 9 : 1 });
}
// NPA trigger follows the facility: CC/OD out-of-order, KCC crop season, everything else overdue days.
if (!this.loanProductPenalNpaForm.get('npaTrigger')?.dirty) {
const trigger = this.isRevolvingSubType ? 'out_of_order' : this.isKccSubType ? 'crop_season' : 'maturity';
this.loanProductPenalNpaForm.patchValue({ npaTrigger: trigger });
}
}
get loanProductPenalNpa() {
return this.loanProductPenalNpaForm.getRawValue();
const value = this.loanProductPenalNpaForm.getRawValue();
// Advances have no instalments, so the 9-month start is carried as arrears-ageing grace (30-day months).
// Loans (EMI) need none: the first instalment falls due one month after disbursement already.
const months = Number(value.smaStartAfterMonths) || 0;
return { ...value, graceOnArrearsAgeing: this.isAdvanceAccount ? months * 30 : 0 };
}
}
@@ -6,12 +6,38 @@
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<mifosx-loan-product-summary
[action]="'preview'"
[loanProduct]="loanProduct"
[loanProductsTemplate]="loanProductsTemplate"
[paymentAllocations]="loanProduct.paymentAllocation"
[creditAllocations]="loanProduct.creditAllocation"
[supportedInterestRefundTypes]="loanProduct.supportedInterestRefundTypes"
>
</mifosx-loan-product-summary>
<div class="rv">
@if (!readOnly) {
<p class="rv__intro">
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval.
</p>
}
@for (section of sections; track section.title) {
<div class="rv__card">
<div class="rv__head"><i class="fas {{ section.icon }}"></i>{{ section.title }}</div>
<div class="rv__grid">
@for (item of section.rows; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</div>
}
@if (otherSettings.length > 0) {
<details class="rv__card rv__other">
<summary class="rv__head"><i class="fas fa-sliders"></i>Other settings ({{ otherSettings.length }})</summary>
<div class="rv__grid">
@for (item of otherSettings; track item.label) {
<div class="rv__row">
<span class="rv__label">{{ item.label }}</span>
<span class="rv__value">{{ item.value }}</span>
</div>
}
</div>
</details>
}
</div>
@@ -47,3 +47,74 @@ mat-divider {
.repayment-strategy {
color: $status-approved;
}
/* Review step: sections of label / value rows. */
.rv {
display: flex;
flex-direction: column;
gap: 14px;
&__intro {
margin: 0;
color: #475569;
font-size: 13px;
}
&__card {
border: 1px solid #e2e8f0;
border-radius: 10px;
background: #fff;
overflow: hidden;
}
&__head {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 16px;
background: #f8fafc;
color: #1e293b;
font-size: 14px;
font-weight: 600;
cursor: default;
i {
color: #6366f1;
}
}
&__other > &__head {
cursor: pointer;
}
&__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 24px;
padding: 6px 16px 10px;
@media (width <= 900px) {
grid-template-columns: 1fr;
}
}
&__row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 7px 0;
border-bottom: 1px solid #f1f5f9;
font-size: 13px;
}
&__label {
color: #64748b;
}
&__value {
color: #1e293b;
font-weight: 500;
text-align: right;
word-break: break-word;
}
}
@@ -6,44 +6,452 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, Output, EventEmitter, OnChanges, SimpleChanges } from '@angular/core';
import { LoanProducts } from '../../loan-products';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { productCategoryLabel, productTypeLabel } from '../../loan-product-types';
interface ReviewRow {
label: string;
value: string;
}
interface ReviewSection {
title: string;
icon: string;
rows: ReviewRow[];
}
/**
* Review step for the Advances and Loans wizards: a plain read-out of what is about to be submitted, in sections, with
* the GL accounts shown by name. (The standard Fineract summary expects option objects this wizard does not build.)
*/
@Component({
selector: 'mifosx-loan-product-preview-step',
templateUrl: './loan-product-preview-step.component.html',
styleUrls: ['./loan-product-preview-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
LoanProductSummaryComponent,
FaIconComponent
]
imports: []
})
export class LoanProductPreviewStepComponent implements OnInit, OnChanges {
export class LoanProductPreviewStepComponent {
@Input() loanProductsTemplate: any;
@Input() accountingRuleData: any;
@Input() loanProduct: any;
@Input() isValid: boolean = true;
@Input() isValid = true;
/** True on the product View page: the reader without the "submit for approval" wording. */
@Input() readOnly = false;
@Output() submitEvent = new EventEmitter();
isAdvancedPaymentAllocation = false;
/** Keys shown in a section above; everything else non-empty goes to "Other settings". */
private readonly shown = new Set<string>();
private cachedFor: any = null;
private cachedSections: ReviewSection[] = [];
private cachedOther: ReviewRow[] = [];
constructor() {}
/** Keys that are technical or already covered, never shown. */
private readonly hidden = new Set<string>([
'locale',
'dateFormat',
'charges',
'accountingRule',
'inMultiplesOf',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'glAccount',
'interestRatePerPeriod'
]);
ngOnInit() {
this.advancedPaymentAllocation();
get sections(): ReviewSection[] {
this.build();
return this.cachedSections;
}
ngOnChanges(changes: SimpleChanges): void {
this.advancedPaymentAllocation();
get otherSettings(): ReviewRow[] {
this.build();
return this.cachedOther;
}
advancedPaymentAllocation() {
this.isAdvancedPaymentAllocation = LoanProducts.isAdvancedPaymentAllocationStrategy(
this.loanProduct.transactionProcessingStrategyCode
);
/** Rebuilt only when the product object changes (it is stable until a form value changes). */
private build(): void {
const p = this.loanProduct;
if (!p || p === this.cachedFor) {
return;
}
this.cachedFor = p;
this.shown.clear();
const row = (label: string, key: string, format?: (value: any) => string): ReviewRow | null => {
this.shown.add(key);
const value = p[key];
if (value === undefined || value === null || value === '' || (Array.isArray(value) && value.length === 0)) {
return null;
}
return { label, value: format ? format(value) : this.format(value) };
};
const gl = (label: string, key: string) => row(label, key, (id) => this.glName(id));
const pct = (value: any) => `${value}%`;
const months = (value: any) => `${value} months`;
const rupees = (value: any) => `₹ ${Number(value).toLocaleString('en-IN')}`;
// An option id such as "fixed_day" read as words ("Fixed day").
const words = (value: any) => {
const text = String(value).replace(/_/g, ' ');
return text.charAt(0).toUpperCase() + text.slice(1);
};
const sections: ReviewSection[] = [
{
title: 'Product',
icon: 'fa-hand-holding-usd',
rows: [
{ label: 'Type', value: productCategoryLabel(p.productTypeGroup) },
{ label: 'Sub type', value: productTypeLabel(p.productTypeGroup, p.productTypeSubGroup) },
row('Name', 'name'),
row('Short name', 'shortName'),
gl('Product GL (asset)', 'glAccount'),
row('Start date', 'startDate'),
row('Close date', 'closeDate'),
row('Currency', 'currencyCode'),
row('Decimal places', 'digitsAfterDecimal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Amount and tenure',
icon: 'fa-coins',
rows: [
row('Facility type', 'facilityType'),
row('Minimum amount / limit', 'minPrincipal', rupees),
row('Default amount / limit', 'principal', rupees),
row('Maximum amount / limit', 'maxPrincipal', rupees),
row('Minimum tenure', 'minNumberOfRepayments', months),
row('Default tenure', 'numberOfRepayments', months),
row('Maximum tenure', 'maxNumberOfRepayments', months),
row('Repayment type', 'repaymentType', (v) =>
v === 'bullet'
? 'Bullet: principal + interest at maturity'
: v === 'running'
? 'Running: any time, clear by limit expiry'
: v
),
row('Appropriation order', 'appropriationOrder', (v) =>
v === 'charges_penal_interest_principal'
? 'Charges, then penal, then interest, then principal'
: v === 'principal_interest_penal_charges'
? 'Principal, then interest, then penal, then charges'
: v
)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest',
icon: 'fa-percent',
rows: [
row('Rate register row', 'registerParticular'),
p.interestRatePerPeriod !== undefined
? { label: 'Rate (default)', value: pct(p.interestRatePerPeriod) }
: null,
row('Spread', 'spread', pct),
row('Calculation method', 'interestCalcMethod'),
row('Interest charged on', 'interestChargedOn'),
row('Posting frequency', 'interestPostingFrequency'),
row('Posting day', 'interestPostingDay')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and lien',
icon: 'fa-shield-halved',
rows: [
row('Security type', 'securityType'),
row(
'Eligible amount basis',
'eligibleAmountBasis',
(v) =>
(
({
gold_value: 'Net weight × purity × bank gold rate',
balance_accrued: 'Deposit balance + accrued interest',
balance: 'Deposit balance only',
surrender: 'Surrender value (LIC)',
commodity_value: 'Quantity × market price of the commodity'
}) as Record<string, string>
)[v] ?? v
),
row('Margin', 'margin', pct),
row('LTV', 'ltv', pct),
row('Lien required', 'lienRequired'),
row('Lien marking stage', 'lienMarkingStage'),
row('Eligible deposit products', 'eligibleDepositProducts'),
row('Lien certificate prefix', 'lienCertificatePrefix'),
row('Tenure cannot exceed deposit maturity', 'lienTenureNotExceedMaturity'),
row('Auto-release lien on closure', 'lienAutoReleaseOnClosure'),
row('Multiple deposits for one advance', 'lienAllowMultipleDeposits'),
row('Adjust from deposit at maturity', 'lienAdjustFromMaturity'),
row('Third-party deposit allowed', 'lienAllowThirdParty'),
row('Deposit renewal under lien', 'lienAllowRenewalUnderLien')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Gold details',
icon: 'fa-ring',
rows: [
row('Maximum LTV cap', 'goldMaxLtv', pct),
row('Minimum purity', 'goldMinPurityCarat', (v) => `${v} carat`),
row('Valuation rate source', 'goldRateSource', (v) =>
v === 'ibja_30d_avg' ? 'IBJA 30-day average' : 'Bank daily gold rate'
),
row('Allowed items', 'goldAllowedItems', (v) =>
v === 'ornaments_coins_50g' ? 'Ornaments + coins (up to 50 g)' : 'Ornaments only'
),
row('Max loan per gram', 'goldMaxPerGram', rupees),
row('Minimum net weight', 'goldMinNetWeight', (v) => `${v} g`),
row('Max net weight per customer', 'goldMaxNetWeightPerCustomer', (v) => `${v} g`),
row('Appraiser required', 'goldAppraiserRequired'),
row('Valuation certificate required', 'goldValuationCertificateRequired'),
row('Max valuation per appraiser', 'goldMaxValuationPerAppraiser', rupees),
row('Packet number prefix', 'goldPacketPrefix'),
row('Revalue gold when loan turns NPA', 'goldRevalueOnNpa'),
row('Charge customer for revaluation', 'goldRevaluationChargeApplicable'),
row('Insurance required', 'insuranceRequired'),
row('Partial release of ornaments', 'goldPartialReleaseAllowed'),
row('Third-party gold allowed', 'goldThirdPartyAllowed'),
row('Disbursement mode', 'goldDisbursementMode', (v) =>
v === 'account_credit' ? 'Account credit only' : 'Account credit; cash up to the limit'
),
row('Cash disbursement limit', 'goldCashDisbursementLimit', rupees),
row('LTV breach alert', 'goldLtvBreachAlert', pct),
row('Auction notice', 'goldAuctionNoticeDays', (v) => `${v} days`),
row('Auction reserve price', 'goldAuctionReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before auction', 'goldRedeemBeforeAuction')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Warehouse receipt details',
icon: 'fa-warehouse',
rows: [
row('Maximum LTV cap', 'wrMaxLtv', pct),
row('Receipt type allowed', 'wrReceiptType', (v) =>
v === 'e_nwr' ? 'e-NWR (electronic)' : v === 'paper_nwr' ? 'Paper NWR' : 'Both e-NWR and paper NWR'
),
row('WDRA-registered warehouse', 'wrWdraRegistered'),
row('Quality / assay certificate', 'wrAssayCertificateRequired'),
row('Tenure within receipt validity', 'wrTenureWithinReceiptValidity'),
row('Pledge marked in repository first', 'wrPledgeMarkedBeforeDisbursement'),
row('Eligible commodity groups', 'wrCommodityGroups', (v) =>
String(v)
.split('|')
.map((id) => id.charAt(0).toUpperCase() + id.slice(1))
.join(', ')
),
row(
'Valuation price source',
'wrValuationPriceSource',
(v) =>
(
({
mandi_apmc: 'Mandi / APMC price',
msp: 'MSP (government support price)',
exchange_ncdex: 'Exchange price (NCDEX)',
bank_assessed: 'Bank-assessed price'
}) as Record<string, string>
)[v] ?? v
),
row('Max loan per quintal', 'wrMaxLoanPerQuintal', rupees),
row('Insurance required', 'insuranceRequired'),
row('Stock inspection every', 'wrStockInspectionMonths', months),
row('LTV breach alert', 'wrLtvBreachAlert', pct),
row('Partial release of stock', 'wrPartialReleaseAllowed'),
row('Disposal notice', 'wrDisposalNoticeDays', (v) => `${v} days`),
row('Disposal reserve price', 'wrDisposalReservePct', (v) => `${v}% of market value`),
row('Customer may redeem before disposal', 'wrRedeemBeforeDisposal')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Borrower eligibility',
icon: 'fa-user',
rows: [
row('Applicant must be a bank member', 'memberRequired'),
row('Guarantors must be bank members', 'guarantorMustBeMember'),
row('Minimum age', 'minAge', (v) => `${v} years`),
row('Maximum age at maturity', 'maxAgeAtMaturity', (v) => `${v} years`),
row('Minimum CIBIL score', 'minCibil'),
row('Minimum monthly income', 'minMonthlyIncome', rupees),
row('Income multiple', 'incomeMultiple', (v) => `${v}× net monthly salary`),
row('Maximum FOIR / DBR', 'maxFoir', pct),
row('Maximum loans per customer', 'maxLoansPerCustomer')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Security and guarantors (EMI loan)',
icon: 'fa-shield-halved',
rows: [
row('Security mandatory', 'securityMandatory', words),
row('Guarantors required', 'guarantorsRequired'),
row('Co-applicant', 'coApplicant', words),
row('Third-party security', 'thirdPartySecurity', words),
row('Legal opinion / title search', 'legalOpinionRequired'),
row('Insurance of the security', 'propertyInsuranceRequired'),
row('Charge registered before disbursement', 'registerCharge'),
row('Release security on closure', 'releaseSecurityOnClosure'),
row('Multiple securities allowed', 'allowMultipleSecurities')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Disbursement',
icon: 'fa-arrow-right',
rows: [
row('Disbursement type', 'disbursementType', words),
row('Disbursement mode', 'disbursementMode', words),
row('Interest on undisbursed amount', 'undisbursedInterest', words),
row('Pre-EMI interest', 'preEmiInterest')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Repayment and EMI',
icon: 'fa-calendar-days',
rows: [
row('EMI due day', 'emiDueDay', words),
row('If the due date is a holiday', 'holidayRule', words),
row('Moratorium', 'moratoriumMonths', months),
row('Interest during moratorium', 'moratoriumInterest', words),
row('Variable instalments', 'variableInstallments'),
row('Repayment mode', 'repaymentMode', words),
row('Linked account type', 'linkedAccountType', words),
row('Cheque (PDC) collection', 'pdcCollection', words),
row('Mandate required', 'mandateRequired'),
row('Advance EMI', 'advanceEmi', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Interest rate settings',
icon: 'fa-percent',
rows: [
row('Rate type', 'rateType', words),
row('CIBIL adjustment applied', 'applyCibilAdjustment'),
row('Rate decided at', 'rateResolvedAt', words),
row('Calculation period', 'calcPeriod', words),
row('Interest starts from', 'interestStartsFrom', words),
row('Compounding', 'compounding', words),
row('Benchmark', 'benchmark', words),
row('Reset frequency', 'resetFrequency', words),
row('On reset, change', 'resetChange', words)
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Prepayment and foreclosure',
icon: 'fa-flag-checkered',
rows: [
row('Part-prepayment allowed', 'partPrepaymentAllowed'),
row('Minimum part-payment', 'minPartPayment', rupees),
row('Part-payment allowed on', 'allowedOn', words),
row('Part-payments per year', 'partPaymentsPerYear', words),
row('Part-payment mode', 'partPrepaymentMode', words),
row('Foreclosure: individual, floating rate', 'foreclosureFloatingIndividual', pct),
row('Foreclosure: individual, fixed rate', 'foreclosureFixedIndividual', pct),
row('Foreclosure: non-individual', 'foreclosureNonIndividual', pct),
row('Foreclosure: takeover', 'foreclosureTakeover', pct),
row('Foreclosure quote valid for', 'foreclosureQuoteValidDays', (v) => `${v} days`),
row('Close automatically at zero balance', 'autoCloseOnZeroBalance'),
row('NOC letter', 'nocLetter', words),
row('Restructuring', 'restructuring', words),
row('Top-up allowed', 'topUpAllowed'),
row('Balance transfer out allowed', 'balanceTransferOutAllowed')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Penal, SMA and NPA',
icon: 'fa-triangle-exclamation',
rows: [
row('Penal charges', 'penalApplicable'),
row('Penal rate', 'penalRate', pct),
row('Penal applied on', 'penalAppliedOn'),
row('Penal grace days', 'penalGraceDays'),
row('SMA counting starts after', 'smaStartAfterMonths', months),
row('NPA after', 'overdueDaysForNPA', (v) => `${v} days overdue`),
row('NPA trigger', 'npaTrigger'),
row('Stock statement validity', 'stockStatementValidityDays', (v) => `${v} days`),
row('Interest after NPA', 'interestAfterNpa'),
row('Upgrade only when all arrears are cleared', 'npaUpgradeOnlyAllArrears'),
row('Keep standard while deposit covers dues', 'npaKeepStandardIfDepositCovers')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Renewal and closure',
icon: 'fa-rotate',
rows: [
row('Renewal allowed', 'renewalAllowed'),
row('Renewal period', 'renewalPeriodMonths', months),
row('Renewal grace', 'renewalGraceDays', (v) => `${v} days`),
row('Pre-closure', 'preclosureAllowed'),
row('Lock-in', 'lockInMonths', months),
row('Interest on pre-closure', 'interestOnPreclosure')
].filter((r): r is ReviewRow => !!r)
},
{
title: 'Charges',
icon: 'fa-receipt',
rows: [row('Attached charges', 'chargesSummary')].filter((r): r is ReviewRow => !!r)
},
{
title: 'Accounting (GL accounts)',
icon: 'fa-book',
rows: [
gl('Loan portfolio', 'loanPortfolioAccountId'),
gl('Fund source', 'fundSourceAccountId'),
gl('Interest income', 'interestOnLoanAccountId'),
gl('Fee income', 'incomeFromFeeAccountId'),
gl('Penal income', 'incomeFromPenaltyAccountId'),
gl('Write-off', 'writeOffAccountId'),
gl('Interest receivable', 'receivableInterestAccountId'),
gl('Overpayment', 'overpaymentLiabilityAccountId'),
gl('Interest suspense', 'undebitedInterestGlId')
].filter((r): r is ReviewRow => !!r)
}
].filter((section) => section.rows.length > 0);
// The group and sub type are shown through the label row above.
this.shown.add('productTypeGroup');
this.shown.add('productTypeSubGroup');
this.cachedSections = sections;
// On the View page the product also carries every raw core field; only the extension record is "other settings".
const candidates = this.readOnly ? Object.keys(p.advanceExtensions ?? {}) : Object.keys(p);
this.cachedOther = candidates
.filter((key) => key !== 'advanceExtensions' && !this.shown.has(key) && !this.hidden.has(key))
.filter(
(key) => p[key] !== undefined && p[key] !== null && p[key] !== '' && !(Array.isArray(p[key]) && !p[key].length)
)
.map((key) => ({ label: this.humanize(key), value: this.format(p[key]) }));
}
private glName(id: any): string {
const options = this.loanProductsTemplate?.accountingMappingOptions ?? {};
const all: any[] = [
...(options.assetAccountOptions ?? []),
...(options.incomeAccountOptions ?? []),
...(options.expenseAccountOptions ?? []),
...(options.liabilityAccountOptions ?? []),
...(options.equityAccountOptions ?? [])
];
const account = all.find((entry) => String(entry.id) === String(id));
return account ? `${account.glCode} ${account.name}` : String(id);
}
private humanize(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ');
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
@@ -27,15 +27,29 @@
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
@if (isGold || isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<input matInput [value]="isGold ? goldBasisLabel : warehouseBasisLabel" disabled />
<mat-hint>
@if (isGold) {
Fixed for Gold: valued on weight, purity and the gold rate
} @else {
Fixed for Warehouse Receipt: valued on quantity and the commodity's market price
}
</mat-hint>
</mat-form-field>
} @else {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
@@ -53,9 +67,424 @@
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
@if (isGold) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="goldMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV (RBI ceiling 75%)</mat-hint>
@if (loanProductSecurityLienForm.get('goldMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
@if (isWarehouse) {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Maximum LTV Cap (%)</mat-label>
<input matInput type="number" formControlName="wrMaxLtv" min="1" max="100" step="0.01" required />
<mat-hint>No account may be sanctioned above this LTV</mat-hint>
@if (loanProductSecurityLienForm.get('wrMaxLtv').hasError('required')) {
<mat-error>Maximum LTV Cap is <strong>required</strong></mat-error>
} @else {
<mat-error>Must be between 1 and 100</mat-error>
}
</mat-form-field>
}
</div>
@if ((isGold || isWarehouse) && loanProductSecurityLienForm.hasError('ltvAboveCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV (100 − Margin) is above the Maximum LTV Cap. Raise the margin or the cap.
</div>
}
</div>
@if (isGold) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-ring"></i>
<h3>Gold Details</h3>
<span class="lsl-section__hint">Pledged ornaments — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation basis</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Minimum Purity (carat)</mat-label>
<mat-select formControlName="goldMinPurityCarat" required>
@for (carat of goldPurityOptions; track carat) {
<mat-option [value]="carat">{{ carat }} carat</mat-option>
}
</mat-select>
<mat-hint>Ornaments below this purity are not accepted</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Rate Source</mat-label>
<mat-select formControlName="goldRateSource" required>
@for (opt of goldRateSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Allowed Items</mat-label>
<mat-select formControlName="goldAllowedItems" required>
@for (opt of goldAllowedItemsOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Gram (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxPerGram" min="1" />
<mat-hint>Optional bank cap on the gold rate</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Min Net Weight (g)</mat-label>
<input matInput type="number" formControlName="goldMinNetWeight" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Net Weight per Customer (g)</mat-label>
<input matInput type="number" formControlName="goldMaxNetWeightPerCustomer" min="0" step="0.01" />
<mat-hint>Optional</mat-hint>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('netWeightRange')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
Minimum net weight cannot exceed the maximum per customer.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Valuation by appraiser</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Appraiser Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Certificate Required</mat-label>
<mat-select formControlName="goldValuationCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Valuation per Appraiser (₹)</mat-label>
<input matInput type="number" formControlName="goldMaxValuationPerAppraiser" min="1" />
<mat-hint>Optional default limit for one appraiser</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Safe custody</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Packet No. Prefix</mat-label>
<input matInput formControlName="goldPacketPrefix" maxlength="20" />
<mat-hint>The packet number itself is set per account</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Revaluation</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldRevalueOnNpa">
Revalue the gold when the loan turns NPA (short-term loans are not revalued on a schedule)
</mat-checkbox>
<mat-checkbox formControlName="goldRevaluationChargeApplicable">
Charge the customer for revaluation (set the fee on the Charges step)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="goldPartialReleaseAllowed">
Partial release of ornaments allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
<mat-checkbox formControlName="goldThirdPartyAllowed">
Third-party gold allowed (relative's gold, consent letter required)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disbursement and monitoring</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disbursement Mode</mat-label>
<mat-select formControlName="goldDisbursementMode" required>
@for (opt of goldDisbursementModeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('goldDisbursementMode').value === 'account_credit_cash_limit') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Cash Disbursement Limit (₹)</mat-label>
<input
matInput
type="number"
formControlName="goldCashDisbursementLimit"
min="0"
[max]="goldCashLimitMax"
/>
<mat-hint>Cash above this is not allowed (Income Tax Sec 269SS)</mat-hint>
<mat-error>Must be between 0 and {{ goldCashLimitMax }}</mat-error>
</mat-form-field>
}
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input
matInput
type="number"
formControlName="goldLtvBreachAlert"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Flag the account when the live LTV (after a gold rate fall) crosses this level</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Auction and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Notice Period (days)</mat-label>
<input matInput type="number" formControlName="goldAuctionNoticeDays" min="0" required />
<mat-hint>Notice given before an auction after NPA</mat-hint>
<mat-error>Auction Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Auction Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="goldAuctionReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Gold is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the gold) any time before the auction. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Packet number, item list, weights, carat, appraiser and valuation date are captured per account at
<strong>Loan Account Creation</strong>, not on the product.
</div>
}
@if (isWarehouse) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-warehouse"></i>
<h3>Warehouse Receipt Details</h3>
<span class="lsl-section__hint">Pledged commodity — no lien marking</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Receipt</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Receipt Type Allowed</mat-label>
<mat-select formControlName="wrReceiptType" required>
@for (opt of wrReceiptTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>e-NWR is pledge-marked online in the repository</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Warehouse WDRA-registered</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Quality / Assay Certificate</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox formControlName="wrTenureWithinReceiptValidity">
Tenure cannot exceed the receipt's validity date
</mat-checkbox>
<mat-checkbox formControlName="wrPledgeMarkedBeforeDisbursement">
Receipt must be pledge-marked in the repository before disbursement
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Commodity and valuation</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Commodity Groups</mat-label>
<mat-select formControlName="wrCommodityGroups" multiple required>
@for (opt of wrCommodityGroupOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-hint>Only receipts for these commodities can be pledged</mat-hint>
<mat-error>Pick at least one commodity group</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Valuation Price Source</mat-label>
<mat-select formControlName="wrValuationPriceSource" required>
@for (opt of wrValuationSourceOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Max Loan per Quintal (₹)</mat-label>
<input matInput type="number" formControlName="wrMaxLoanPerQuintal" min="1" />
<mat-hint>Optional bank cap on the price</mat-hint>
<mat-error>Must be above 0</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Insurance and inspection</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Insurance Required</mat-label>
<mat-select formControlName="insuranceRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Stock Inspection Every (months)</mat-label>
<input matInput type="number" formControlName="wrStockInspectionMonths" min="1" required />
<mat-hint>Physical check that the stock is in the warehouse</mat-hint>
<mat-error>Required, at least 1</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV Breach Alert (%)</mat-label>
<input matInput type="number" formControlName="wrLtvBreachAlert" min="1" max="100" step="0.01" required />
<mat-hint>Flag the account when a price fall pushes the live LTV above this</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
@if (loanProductSecurityLienForm.hasError('alertBelowCap')) {
<div class="lsl-error">
<i class="fas fa-circle-exclamation"></i>
LTV Breach Alert cannot be below the Maximum LTV Cap.
</div>
}
</div>
<div class="lsl-subsection">
<p class="lsl-group">Loan servicing rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="wrPartialReleaseAllowed">
Partial release of stock allowed on part payment (LTV must stay within the cap)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Disposal and recovery</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Notice Period (days)</mat-label>
<input matInput type="number" formControlName="wrDisposalNoticeDays" min="0" required />
<mat-hint>Notice given before the stock is sold after NPA</mat-hint>
<mat-error>Disposal Notice Period is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Disposal Reserve Price (% of market value)</mat-label>
<input
matInput
type="number"
formControlName="wrDisposalReservePct"
min="1"
max="100"
step="0.01"
required
/>
<mat-hint>Stock is not sold below this share of the market value</mat-hint>
<mat-error>Required, between 1 and 100</mat-error>
</mat-form-field>
</div>
<div class="lsl-checks lsl-checks--spaced">
<mat-checkbox [checked]="true" disabled>
Customer may redeem (clear dues and take the stock) any time before the disposal. Always on
</mat-checkbox>
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Commodity, quantity, grade, warehouse, receipt number and validity, repository pledge reference and the market
price on the sanction date are captured per account at <strong>Loan Account Creation</strong>, not on the
product.
</div>
}
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
@@ -115,3 +115,20 @@ $gray-900: #202124;
color: $accent;
}
}
.lsl-error {
display: flex;
align-items: center;
gap: 8px;
margin: 8px 24px 16px;
padding: 10px 14px;
border: 1px solid #fecaca;
border-radius: 8px;
background: #fef2f2;
font-size: 13px;
color: #b91c1c;
}
.lsl-checks--spaced {
margin-top: 14px;
}
@@ -67,9 +67,65 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
fd: 10,
rd: 10,
dds: 10,
lic: 40
lic: 40,
gold: 25,
warehouse: 25
};
/** RBI ceiling a gold loan may be sanctioned at; the product's own LTV cap defaults to it. */
private readonly goldDefaultLtvCap = 75;
readonly goldPurityOptions = [
18,
20,
22,
24
];
readonly goldRateSourceOptions = [
{ id: 'bank_daily', label: 'Bank daily gold rate' },
{ id: 'ibja_30d_avg', label: 'IBJA 30-day average' }
];
readonly goldAllowedItemsOptions = [
{ id: 'ornaments_only', label: 'Ornaments only' },
{ id: 'ornaments_coins_50g', label: 'Ornaments + coins (up to 50 g)' }
];
readonly goldBasisLabel = 'Net weight × purity × bank gold rate';
// ---- Loan against Warehouse Receipt ----
readonly warehouseBasisLabel = 'Quantity × market price of the commodity';
readonly wrReceiptTypeOptions = [
{ id: 'e_nwr', label: 'e-NWR (electronic)' },
{ id: 'paper_nwr', label: 'Paper NWR' },
{ id: 'both', label: 'Both' }
];
readonly wrCommodityGroupOptions = [
{ id: 'cereals', label: 'Cereals' },
{ id: 'pulses', label: 'Pulses' },
{ id: 'oilseeds', label: 'Oilseeds' },
{ id: 'spices', label: 'Spices' },
{ id: 'others', label: 'Others' }
];
readonly wrValuationSourceOptions = [
{ id: 'mandi_apmc', label: 'Mandi / APMC price' },
{ id: 'msp', label: 'MSP (government support price)' },
{ id: 'exchange_ncdex', label: 'Exchange price (NCDEX)' },
{ id: 'bank_assessed', label: 'Bank-assessed price' }
];
readonly goldDisbursementModeOptions = [
{ id: 'account_credit', label: 'Account credit only' },
{ id: 'account_credit_cash_limit', label: 'Account credit; cash up to the limit' }
];
/** The most that may be paid out in cash against one gold loan (Income Tax Sec 269SS). */
readonly goldCashLimitMax = 20000;
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
@@ -85,9 +141,18 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme'
'Daily Deposit Scheme',
'LIC Policy'
];
/** The deposit or policy a sub type is lent against; this is the default "Eligible Deposit Products". */
private readonly defaultDepositBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit – Regular',
rd: 'Recurring Deposit – Regular',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy'
};
readonly yesNoOptions = [
'YES',
'NO'
@@ -111,6 +176,23 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
// Gold is valued on weight, purity and the gold rate; the deposit bases apply to every other type.
const basis = this.loanProductSecurityLienForm.get('eligibleAmountBasis');
if (subGroup === 'gold') {
basis.setValue('gold_value', { emitEvent: false });
} else if (subGroup === 'warehouse') {
basis.setValue('commodity_value', { emitEvent: false });
} else if (basis.value === 'gold_value' || basis.value === 'commodity_value') {
basis.setValue('balance_accrued', { emitEvent: false });
}
this.loanProductSecurityLienForm.updateValueAndValidity({ emitEvent: false });
// The deposit list follows the sub type until the officer has chosen their own.
// A sub type with no deposit of its own (gold, CC ...) keeps the plain FD default; the list is hidden for it.
const deposit = this.defaultDepositBySubGroup[subGroup] ?? 'Fixed Deposit – Regular';
const deposits = this.loanProductSecurityLienForm.get('eligibleDepositProducts');
if (deposit && deposits && !deposits.dirty) {
deposits.setValue([deposit], { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
@@ -118,6 +200,16 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
return this.productTypeGroup?.value === 'advances';
}
/** Loan against Gold: pledged ornaments, valued by an appraiser; no lien or deposit list. */
get isGold(): boolean {
return this.productTypeSubGroup?.value === 'gold';
}
/** Loan against Warehouse Receipt: a pledged commodity, valued on quantity and market price; no lien or deposit list. */
get isWarehouse(): boolean {
return this.productTypeSubGroup?.value === 'warehouse';
}
/** The lien block shows only for deposit / policy sub types; gold, warehouse, CC etc. are pledged. */
get isLienSubType(): boolean {
return this.lienSubGroups.includes(this.productTypeSubGroup?.value);
@@ -133,41 +225,216 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
}
createForm() {
this.loanProductSecurityLienForm = this.formBuilder.group({
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
this.loanProductSecurityLienForm = this.formBuilder.group(
{
securityType: [{ value: '', disabled: true }],
eligibleAmountBasis: [
'balance_accrued',
Validators.required
],
margin: [
10,
[
Validators.required,
Validators.min(0),
Validators.max(100)
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES'],
// ---- Loan against Gold (hidden and not sent for any other type) ----
goldMaxLtv: [
this.goldDefaultLtvCap,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldMinPurityCarat: [
18,
Validators.required
],
goldRateSource: [
'bank_daily',
Validators.required
],
goldMaxPerGram: [
'',
Validators.min(1)
],
goldMinNetWeight: [
'',
Validators.min(0)
],
goldMaxNetWeightPerCustomer: [
'',
Validators.min(0)
],
goldAllowedItems: [
'ornaments_only',
Validators.required
],
goldValuationCertificateRequired: ['YES'],
goldMaxValuationPerAppraiser: [
'',
Validators.min(1)
],
goldPacketPrefix: [
'GL/',
Validators.maxLength(20)
],
goldRevalueOnNpa: [true],
goldRevaluationChargeApplicable: [true],
insuranceRequired: ['YES'],
goldAuctionNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
goldPartialReleaseAllowed: [true],
goldThirdPartyAllowed: [false],
goldDisbursementMode: [
'account_credit_cash_limit',
Validators.required
],
goldCashDisbursementLimit: [
this.goldCashLimitMax,
[
Validators.min(0),
Validators.max(this.goldCashLimitMax)
]
],
goldLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
goldAuctionReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
// ---- Loan against Warehouse Receipt (hidden and not sent for any other type) ----
wrMaxLtv: [
75,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrReceiptType: [
'e_nwr',
Validators.required
],
wrTenureWithinReceiptValidity: [true],
wrPledgeMarkedBeforeDisbursement: [true],
wrCommodityGroups: [
[
'cereals',
'pulses',
'oilseeds'
],
Validators.required
],
wrValuationPriceSource: [
'mandi_apmc',
Validators.required
],
wrMaxLoanPerQuintal: [
'',
Validators.min(1)
],
wrStockInspectionMonths: [
3,
[
Validators.required,
Validators.min(1)
]
],
wrLtvBreachAlert: [
80,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
],
wrPartialReleaseAllowed: [true],
wrDisposalNoticeDays: [
15,
[
Validators.required,
Validators.min(0)
]
],
wrDisposalReservePct: [
90,
[
Validators.required,
Validators.min(1),
Validators.max(100)
]
]
],
lienMarkingStage: [
'disbursement',
Validators.required
],
eligibleDepositProducts: [
['Fixed Deposit – Regular'],
Validators.required
],
tenureNotExceedMaturity: [true],
autoReleaseOnClosure: [true],
allowMultipleDeposits: [false],
adjustFromMaturity: [true],
allowThirdPartyDeposit: [false],
allowRenewalUnderLien: [true],
lienCertificateRequired: ['YES'],
lienCertificatePrefix: [
'LC/FD/',
Validators.required
],
printOnDisbursement: ['YES']
});
},
{ validators: (group: AbstractControl) => this.pledgeRules(group) }
);
}
/**
* Pledged-security rules (Gold, Warehouse): the LTV the margin gives must fit under the product's LTV cap, the breach
* alert must not sit below the cap, and a gold net-weight range must run low to high.
*/
private pledgeRules(group: AbstractControl): Record<string, boolean> | null {
const gold = this.isGold;
if (!gold && !this.isWarehouse) {
return null;
}
const errors: Record<string, boolean> = {};
const margin = Number(group.get('margin')?.value);
const cap = Number(group.get(gold ? 'goldMaxLtv' : 'wrMaxLtv')?.value);
if (!isNaN(margin) && !isNaN(cap) && 100 - margin > cap) {
errors['ltvAboveCap'] = true;
}
const alert = Number(group.get(gold ? 'goldLtvBreachAlert' : 'wrLtvBreachAlert')?.value);
if (!isNaN(alert) && !isNaN(cap) && alert < cap) {
errors['alertBelowCap'] = true;
}
if (gold) {
const min = group.get('goldMinNetWeight')?.value;
const max = group.get('goldMaxNetWeightPerCustomer')?.value;
if (min !== '' && max !== '' && min !== null && max !== null && Number(min) > Number(max)) {
errors['netWeightRange'] = true;
}
}
return Object.keys(errors).length ? errors : null;
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
@@ -190,7 +457,45 @@ export class LoanProductSecurityLienStepComponent implements OnInit {
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement
printOnDisbursement: value.printOnDisbursement,
gold: {
maxLtv: value.goldMaxLtv,
minPurityCarat: value.goldMinPurityCarat,
rateSource: value.goldRateSource,
maxPerGram: value.goldMaxPerGram,
minNetWeight: value.goldMinNetWeight,
maxNetWeightPerCustomer: value.goldMaxNetWeightPerCustomer,
allowedItems: value.goldAllowedItems,
valuationCertificateRequired: value.goldValuationCertificateRequired,
maxValuationPerAppraiser: value.goldMaxValuationPerAppraiser,
packetPrefix: value.goldPacketPrefix,
revalueOnNpa: value.goldRevalueOnNpa,
revaluationChargeApplicable: value.goldRevaluationChargeApplicable,
insuranceRequired: value.insuranceRequired,
auctionNoticeDays: value.goldAuctionNoticeDays,
partialReleaseAllowed: value.goldPartialReleaseAllowed,
thirdPartyAllowed: value.goldThirdPartyAllowed,
disbursementMode: value.goldDisbursementMode,
cashDisbursementLimit: value.goldCashDisbursementLimit,
ltvBreachAlert: value.goldLtvBreachAlert,
auctionReservePct: value.goldAuctionReservePct,
redeemBeforeAuction: true
},
warehouse: {
maxLtv: value.wrMaxLtv,
receiptType: value.wrReceiptType,
tenureWithinReceiptValidity: value.wrTenureWithinReceiptValidity,
pledgeMarkedBeforeDisbursement: value.wrPledgeMarkedBeforeDisbursement,
commodityGroups: value.wrCommodityGroups,
valuationPriceSource: value.wrValuationPriceSource,
maxLoanPerQuintal: value.wrMaxLoanPerQuintal,
stockInspectionMonths: value.wrStockInspectionMonths,
insuranceRequired: value.insuranceRequired,
ltvBreachAlert: value.wrLtvBreachAlert,
partialReleaseAllowed: value.wrPartialReleaseAllowed,
disposalNoticeDays: value.wrDisposalNoticeDays,
disposalReservePct: value.wrDisposalReservePct
}
};
}
}
@@ -45,6 +45,11 @@ export interface RegisterRateSelection {
const PARTICULAR_BY_SUB_TYPE: Record<string, string> = {
fd: 'against fdr',
rd: 'against rd',
dds: 'against dds',
lic: 'against lic',
warehouse: 'warehouse',
bills: 'bills purchased',
education: 'education loan',
gold: 'gold loan',
cc: 'cash credit',
overdraft: 'overdraft',
@@ -81,9 +81,33 @@
placeholder="Search by name or short name…"
/>
</div>
<div class="pl-filters">
<label class="pl-filter">
<span>Category</span>
<select [(ngModel)]="categoryFilter" (ngModelChange)="onCategoryChange()">
<option value="">All categories</option>
@for (cat of categories; track cat.id) {
<option [value]="cat.id">{{ cat.label }}</option>
}
</select>
</label>
<label class="pl-filter">
<span>Product type</span>
<select [(ngModel)]="typeFilter" (ngModelChange)="refreshFilter()">
<option value="">All types</option>
@for (type of typeOptions; track type.id) {
<option [value]="type.id">{{ type.label }}</option>
}
</select>
</label>
<label class="pl-filter pl-filter--check">
<input type="checkbox" [(ngModel)]="activeOnly" (ngModelChange)="refreshFilter()" />
<span>Active only</span>
</label>
</div>
</div>
@if (totalCount === 0) {
@if (totalCount === 0 && pendingRows.length === 0) {
<div class="pl-empty">
<div class="pl-empty-ic"><fa-icon icon="hand-holding-usd"></fa-icon></div>
<h3>No loan products yet</h3>
@@ -123,6 +147,16 @@
</td>
</ng-container>
<ng-container matColumnDef="category">
<th mat-header-cell *matHeaderCellDef>Category</th>
<td mat-cell *matCellDef="let p">{{ categoryOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="productType">
<th mat-header-cell *matHeaderCellDef>Product Type</th>
<td mat-cell *matCellDef="let p">{{ typeOf(p) }}</td>
</ng-container>
<ng-container matColumnDef="closeDate">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Expiry Date' | translate }}</th>
<td mat-cell *matCellDef="let p">
@@ -137,48 +171,100 @@
<ng-container matColumnDef="status">
<th mat-header-cell *matHeaderCellDef mat-sort-header>{{ 'labels.inputs.Status' | translate }}</th>
<td mat-cell *matCellDef="let p">
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (p.pending) {
<span
class="pl-status-pill pl-status-pill--pending"
matTooltip="Requested by {{ p.madeBy }}; visible as a product once a checker approves"
>
<fa-icon icon="hourglass-half"></fa-icon> Pending approval
</span>
} @else {
<span
class="pl-status-pill"
[class.pl-status-pill--active]="p.status === 'loanProduct.active'"
[class.pl-status-pill--inactive]="p.status !== 'loanProduct.active'"
>
<fa-icon [icon]="p.status === 'loanProduct.active' ? 'check-circle' : 'times-circle'"></fa-icon>
{{
p.status === 'loanProduct.active'
? ('labels.inputs.Active' | translate)
: ('labels.catalogs.Inactive' | translate)
}}
</span>
@if (pendingUpdates.get(p.id); as update) {
<button
type="button"
class="pl-status-pill pl-status-pill--pending"
style="margin-left: 6px; cursor: pointer"
matTooltip="An edit by {{ update.maker }} is waiting for a checker. Click to see what changes."
(click)="$event.stopPropagation(); viewUpdate(p)"
>
<fa-icon icon="hourglass-half"></fa-icon> Update pending: view changes
</button>
}
}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell *matHeaderCellDef class="num">Actions</th>
<td mat-cell *matCellDef="let p" class="num">
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
(click)="$event.stopPropagation()"
matTooltip="Edit this product"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
@if (p.pending) {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
(click)="$event.stopPropagation(); viewPending(p)"
matTooltip="View the submitted details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
disabled
(click)="$event.stopPropagation()"
matTooltip="Locked until the request is approved or rejected"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
} @else {
<button
type="button"
class="pl-action-btn pl-action-btn--view"
[routerLink]="[p.id]"
(click)="$event.stopPropagation()"
matTooltip="View product details"
>
<fa-icon icon="eye"></fa-icon><span>View</span>
</button>
<button
type="button"
class="pl-action-btn pl-action-btn--edit"
[routerLink]="[p.id, 'edit']"
[disabled]="pendingUpdates.has(p.id)"
(click)="$event.stopPropagation()"
[matTooltip]="
pendingUpdates.has(p.id)
? 'Locked until the pending edit is approved or rejected'
: 'Edit this product'
"
*mifosxHasPermission="'UPDATE_LOANPRODUCT'"
>
<fa-icon icon="edit"></fa-icon><span>Edit</span>
</button>
}
</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" [routerLink]="[row.id]" class="pl-row"></tr>
<tr
mat-row
*matRowDef="let row; columns: displayedColumns"
[routerLink]="row.pending ? null : [row.id]"
(click)="row.pending && viewPending(row)"
[class.pl-row--pending]="row.pending"
class="pl-row"
></tr>
</table>
@if (filteredCount === 0 && totalCount > 0) {
@@ -61,3 +61,18 @@
background: #ede9fe;
color: #5b21b6;
}
.pl-status-pill--pending {
background: #fef3c7;
color: #92400e;
border: 1px solid #fcd34d;
}
.pl-row--pending {
background: #fffbeb;
}
.pl-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@@ -8,6 +8,7 @@
/** Angular Imports */
import { Component, OnInit, TemplateRef, ElementRef, ViewChild, AfterViewInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import { MatDialog } from '@angular/material/dialog';
@@ -27,8 +28,8 @@ import {
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
/** rxjs Imports */
import { switchMap, catchError, finalize } from 'rxjs/operators';
import { EMPTY } from 'rxjs';
import { switchMap, catchError, finalize, map, tap } from 'rxjs/operators';
import { EMPTY, forkJoin, of } from 'rxjs';
/* Custom Services */
import { PopoverService } from '../../configuration-wizard/popover/popover.service';
@@ -42,6 +43,9 @@ import { MatTooltip } from '@angular/material/tooltip';
import { StatusLookupPipe } from '../../pipes/status-lookup.pipe';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { ProductEditDiffDialogComponent } from 'app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component';
import { withExtensions } from './loan-product-original.util';
import { PendingProductViewDialogComponent } from './pending-product-view-dialog/pending-product-view-dialog.component';
import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTypeLabel } from './loan-product-types';
@Component({
@@ -50,6 +54,7 @@ import { PRODUCT_SUB_TYPES, PRODUCT_TYPE_GROUPS, productCategoryLabel, productTy
styleUrls: ['./loan-products.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent,
MatTable,
MatSort,
@@ -80,6 +85,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
private errorHandler = inject(ErrorHandlerService);
loanProductsData: any[] = [];
/** Create requests a checker has not approved yet; shown in the table with a Pending Approval status. */
pendingRows: any[] = [];
/** Saved products that have an edit waiting for a checker, by product id (value: the pending request). */
pendingUpdates = new Map<number, any>();
displayedColumns: string[] = [
'name',
'shortName',
@@ -130,8 +139,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
return this.totalCount - this.activeCount;
}
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
@ViewChild(MatSort, { static: true }) sort: MatSort;
@ViewChild(MatPaginator) paginator: MatPaginator;
@ViewChild(MatSort) sort: MatSort;
/* Reference of create loan product button */
@ViewChild('buttonCreateLoanProduct') buttonCreateLoanProduct: ElementRef<any>;
@@ -160,7 +169,75 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
this.dataSource.sort = this.sort;
this.dataSource.filterPredicate = (product: any) => this.matchesFilters(product);
this.filteredCount = this.totalCount;
this.dataSource.connect().subscribe((rows) => (this.filteredCount = rows.length));
this.dataSource.connect().subscribe(() => (this.filteredCount = this.dataSource.filteredData.length));
this.loadPendingRequests();
}
/** Pending loan product requests become table rows, so a submitted product is visible straight away. */
private loadPendingRequests(): void {
this.productsService
.getPendingMakerCheckerByEntity('LOANPRODUCT')
.pipe(
catchError(() => of([])),
tap((entries: any[]) => {
this.pendingUpdates = new Map(
(entries ?? [])
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'UPDATE' && entry.resourceId != null)
.map(
(entry) => [
Number(entry.resourceId),
entry
] as [number, any]
)
);
}),
switchMap((entries: any[]) =>
(entries ?? []).filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE').length
? forkJoin(
entries
.filter((entry) => (entry.actionName ?? '').toUpperCase() === 'CREATE')
.map((entry) =>
this.productsService.getLoanRateRegisterAuditDetail(entry.id).pipe(
catchError(() => of(null)),
map((audit) => this.pendingRow(entry, audit))
)
)
)
: of([])
)
)
.subscribe((rows: any[]) => {
this.pendingRows = rows;
this.dataSource.data = [
...rows,
...this.loanProductsData
];
});
}
private pendingRow(entry: any, audit: any): any {
let payload: any = {};
try {
payload = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
payload = {};
}
return {
id: null,
pending: true,
pendingRequestId: entry.id,
name: payload.name ?? 'New loan product',
shortName: payload.shortName ?? '—',
currency: payload.currencyCode ? { code: payload.currencyCode } : null,
closeDate: null,
status: 'pending',
madeBy: entry.maker,
payload,
advanceExtensions: {
productTypeGroup: payload.productTypeGroup,
productTypeSubGroup: payload.productTypeSubGroup
}
};
}
applyFilter(filterValue: string) {
@@ -210,6 +287,8 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
* To show popover.
*/
ngAfterViewInit() {
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
if (this.configurationWizardService.showLoanProductsPage) {
setTimeout(() => {
this.showPopover(
@@ -267,6 +346,34 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
/**
* Opens the import loan product dialog.
*/
/** What a pending edit would change: the saved product and the submitted edit, side by side. */
viewUpdate(row: any): void {
const request = this.pendingUpdates.get(Number(row.id));
if (!request) {
return;
}
forkJoin({
audit: this.productsService.getLoanRateRegisterAuditDetail(request.id),
product: this.productsService.getLoanProduct(row.id, true)
}).subscribe(({ audit, product }: any) => {
let proposed: any = {};
try {
proposed = audit?.commandAsJson ? JSON.parse(audit.commandAsJson) : {};
} catch {
proposed = {};
}
this.dialog.open(ProductEditDiffDialogComponent, {
width: '820px',
data: { productName: row.name, original: withExtensions(product), proposed }
});
});
}
/** A pending product has no page yet, so its submitted values open in a read-only dialog. */
viewPending(row: any): void {
this.dialog.open(PendingProductViewDialogComponent, { width: '40rem', data: row });
}
openImportDialog(): void {
const importDialogRef = this.dialog.open(ImportLoanProductDialogComponent, {
width: '50rem'
@@ -318,7 +425,10 @@ export class LoanProductsComponent implements OnInit, AfterViewInit {
.subscribe({
next: (data: any) => {
this.loanProductsData = data;
this.dataSource.data = this.loanProductsData;
this.dataSource.data = [
...this.pendingRows,
...this.loanProductsData
];
this.errorHandler.showSuccess('Loan product imported successfully!');
},
error: () => {
@@ -0,0 +1,39 @@
<!--
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/.
-->
<h1 mat-dialog-title>{{ data.name }}</h1>
<mat-dialog-content>
<div class="ppv__banner">
<i class="fas fa-hourglass-half"></i>
<span>
Awaiting checker approval. Requested by <strong>{{ data.madeBy }}</strong
>. This product is not active yet and cannot be edited until the request is approved or rejected.
</span>
</div>
<div class="ppv__chips">
<span class="ppv__chip">{{ category }}</span>
<span class="ppv__chip">{{ type }}</span>
<span class="ppv__chip">Short name: {{ data.shortName }}</span>
</div>
<table class="ppv__table">
<tbody>
@for (field of fields; track field.label) {
<tr>
<th>{{ field.label }}</th>
<td>{{ field.value }}</td>
</tr>
}
</tbody>
</table>
</mat-dialog-content>
<mat-dialog-actions align="end">
<button type="button" mat-dialog-close class="ppv__close">Close</button>
</mat-dialog-actions>
@@ -0,0 +1,74 @@
/**
* 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/.
*/
.ppv__banner {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 10px 14px;
margin-bottom: 12px;
border: 1px solid #fcd34d;
border-radius: 8px;
background: #fffbeb;
color: #78350f;
font-size: 13px;
i {
margin-top: 2px;
color: #d97706;
}
}
.ppv__chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.ppv__chip {
padding: 3px 10px;
border-radius: 999px;
background: #fef3c7;
color: #92400e;
font-size: 12px;
font-weight: 600;
}
.ppv__table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 6px 8px;
border-bottom: 1px solid #f1f5f9;
text-align: left;
vertical-align: top;
}
th {
width: 42%;
font-weight: 500;
color: #64748b;
}
td {
color: #1e293b;
word-break: break-word;
}
}
.ppv__close {
padding: 6px 16px;
border: 1px solid #cbd5e1;
border-radius: 6px;
background: #fff;
cursor: pointer;
}
@@ -0,0 +1,78 @@
/**
* 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 { Component, inject } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
MatDialogClose,
MatDialogContent,
MatDialogTitle
} from '@angular/material/dialog';
import { productCategoryLabel, productTypeLabel } from '../loan-product-types';
/** Submitted values that carry no information for a reader. */
const HIDDEN_KEYS = new Set([
'locale',
'dateFormat',
'charges'
]);
/**
* Read-only view of a loan product request that is still awaiting a checker. The product does not exist yet, so the
* page is built from what the maker submitted (the request payload).
*/
@Component({
selector: 'mifosx-pending-product-view-dialog',
templateUrl: './pending-product-view-dialog.component.html',
styleUrls: ['./pending-product-view-dialog.component.scss'],
imports: [
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose
]
})
export class PendingProductViewDialogComponent {
/** The pending table row: name, shortName, madeBy and the submitted payload. */
data = inject<any>(MAT_DIALOG_DATA);
get category(): string {
return productCategoryLabel(this.data?.payload?.productTypeGroup);
}
get type(): string {
return productTypeLabel(this.data?.payload?.productTypeGroup, this.data?.payload?.productTypeSubGroup);
}
/** Every submitted field as label / value, hiding empty values and technical keys. */
get fields(): { label: string; value: string }[] {
const payload = this.data?.payload ?? {};
return Object.keys(payload)
.filter((key) => !HIDDEN_KEYS.has(key))
.filter((key) => payload[key] !== null && payload[key] !== undefined && payload[key] !== '')
.map((key) => ({ label: this.label(key), value: this.format(payload[key]) }));
}
private label(key: string): string {
const spaced = key.replace(/([a-z0-9])([A-Z])/g, '$1 $2').replace(/_/g, ' ');
return spaced.charAt(0).toUpperCase() + spaced.slice(1);
}
private format(value: any): string {
if (typeof value === 'boolean') {
return value ? 'Yes' : 'No';
}
if (Array.isArray(value)) {
return value.length
? value.map((entry) => (typeof entry === 'object' ? JSON.stringify(entry) : entry)).join(', ')
: '—';
}
return typeof value === 'object' ? JSON.stringify(value) : String(value);
}
}
@@ -48,6 +48,14 @@
</div>
</div>
@if (advanceReview) {
<mifosx-loan-product-preview-step
[loanProduct]="advanceReview.product"
[loanProductsTemplate]="advanceReview.template"
[readOnly]="true"
></mifosx-loan-product-preview-step>
}
<!-- Summary content (rendered by shared component) -->
<div class="pv-legacy">
<mifosx-loan-product-summary
@@ -8,6 +8,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { HttpClient } from '@angular/common/http';
import { LoanProduct } from '../../models/loan-product.model';
import { FormfieldBase } from 'app/shared/form-dialog/formfield/model/formfield-base';
import { InputBase } from 'app/shared/form-dialog/formfield/model/input-base';
@@ -19,6 +20,8 @@ import { SettingsService } from 'app/settings/settings.service';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductSummaryComponent } from '../../common/loan-product-summary/loan-product-summary.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { LoanProductPreviewStepComponent } from '../../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
import { reviewProductOf } from '../../loan-product-original.util';
@Component({
selector: 'mifosx-general-tab',
@@ -27,7 +30,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
LoanProductSummaryComponent
LoanProductSummaryComponent,
LoanProductPreviewStepComponent
]
})
export class GeneralTabComponent implements OnInit {
@@ -37,8 +41,11 @@ export class GeneralTabComponent implements OnInit {
private productsService = inject(ProductsService);
private settingsService = inject(SettingsService);
private translateService = inject(TranslateService);
private http = inject(HttpClient);
loanProduct: LoanProduct;
/** An Advances product: its own settings live in the extension record the standard summary does not read. */
advanceReview: { product: any; template: any } | null = null;
constructor() {
this.route.data.subscribe((data: { loanProduct: any }) => {
@@ -47,11 +54,45 @@ export class GeneralTabComponent implements OnInit {
}
ngOnInit() {
const savedGroup = (this.loanProduct as any)?.advanceExtensions?.productTypeGroup;
if (savedGroup === 'advances' || savedGroup === 'loans') {
this.advanceReview = reviewProductOf(this.loanProduct);
this.nameSuspenseGl();
}
this.loanProduct.allowAttributeConfiguration = Object.values(this.loanProduct.allowAttributeOverrides).some(
(attribute: boolean) => attribute
);
}
/** The interest suspense GL lives on the extension record, not in the accounting mappings: look its name up. */
private nameSuspenseGl(): void {
const id = this.advanceReview?.product?.undebitedInterestGlId;
if (id === undefined || id === null) {
return;
}
this.http.get(`/glaccounts/${id}`).subscribe({
next: (account: any) => {
const current = this.advanceReview;
const options = current.template.accountingMappingOptions;
// A new object, so the Review reader rebuilds with the name.
this.advanceReview = {
product: { ...current.product },
template: {
accountingMappingOptions: {
...options,
assetAccountOptions: [
...options.assetAccountOptions,
{ id: account.id, glCode: account.glCode, name: account.name }]
}
}
};
},
error: () => {
/* the id stays as the fallback text */
}
});
}
exportDefinition(): void {
const product = this.loanProduct;
delete product['id'];
@@ -48,6 +48,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" required formControlName="maximumAgeDays" min="1" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -61,15 +61,18 @@ export class CreateRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*)$'),
Validators.max(10000)
]
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: ['']
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data = {
const data: any = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.createDelinquencyRange(data).subscribe((response: any) => {
@@ -47,6 +47,17 @@
<mat-label>{{ 'labels.inputs.Days Till' | translate }}</mat-label>
<input matInput type="number" formControlName="maximumAgeDays" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Stage (RBI 2026 ECL)</mat-label>
<mat-select formControlName="stage">
<mat-option [value]="''">— None (informational only) —</mat-option>
<mat-option [value]="1">Stage 1 — Standard / performing</mat-option>
<mat-option [value]="2">Stage 2 — SICR / underperforming</mat-option>
<mat-option [value]="3">Stage 3 — NPA / credit-impaired</mat-option>
</mat-select>
<mat-hint>Drives staging for ECL provisioning; leave blank for a non-staging range.</mat-hint>
</mat-form-field>
</div>
</div>
@@ -68,15 +68,18 @@ export class EditRangeComponent implements OnInit {
Validators.pattern('^(0*[1-9][0-9]*?)$'),
Validators.max(10000)
]
]
],
// RBI 2026 ECL framework: which asset-classification stage this range maps to (optional).
stage: [this.delinquencyRangeData.stage ?? '']
});
}
submit(): void {
const delinquencyRangeFormData = this.delinquencyRangeForm.value;
const locale = this.settingsService.language.code;
const data = {
const data: any = {
...delinquencyRangeFormData,
stage: delinquencyRangeFormData.stage === '' ? undefined : Number(delinquencyRangeFormData.stage),
locale
};
this.productsService.updateDelinquencyRange(this.delinquencyRangeData.id, data).subscribe((response: any) => {
@@ -52,6 +52,25 @@
<span class="vr-label">{{ 'labels.inputs.Days Till' | translate }}</span>
<span class="vr-value">{{ delinquencyRangeData.maximumAgeDays }}</span>
</div>
<div class="vr-row">
<span class="vr-label">Stage (RBI 2026 ECL)</span>
<span class="vr-value">
@switch (delinquencyRangeData.stage) {
@case (1) {
Stage 1 — Standard / performing
}
@case (2) {
Stage 2 — SICR / underperforming
}
@case (3) {
Stage 3 — NPA / credit-impaired
}
@default {
— None (informational only) —
}
}
</span>
</div>
</div>
</div>
</div>
@@ -34,5 +34,16 @@
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
<div class="mdb-card" [routerLink]="['floor-master']" *mifosxHasPermission="'READ_PRUDENTIAL_FLOOR'">
<div class="mdb-card__icon-wrap">
<i class="fas fa-chart-line"></i>
</div>
<div class="mdb-card__content">
<h3>Prudential Floor Master</h3>
<p>RBI 2026 ECL stage-wise provisioning floor, by product category</p>
</div>
<i class="fas fa-chevron-right mdb-card__arrow"></i>
</div>
</div>
</div>
@@ -0,0 +1,64 @@
<!--
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/.
-->
<div class="container">
<div class="table-header">
<div class="table-title-group">
<h2 class="table-title">
<i class="fas fa-chart-line title-icon"></i>
Prudential Floor Master
</h2>
<p class="pf-subtitle">
RBI 2026 ECL framework (para 82) — regulatory minimum provisioning floor by product category and stage. Seeded
from the RBI Directions; read-only.
</p>
</div>
<div class="table-actions">
<div class="table-search">
<mat-form-field appearance="outline">
<i matPrefix class="fas fa-search"></i>
<mat-label>{{ 'labels.inputs.Filter' | translate }}</mat-label>
<input matInput (keyup)="applyFilter($event.target.value)" />
</mat-form-field>
</div>
</div>
</div>
<div class="table-container">
<table mat-table [dataSource]="dataSource" matSort class="modern-table">
<ng-container matColumnDef="productCategory">
<th mat-header-cell *matHeaderCellDef mat-sort-header="productCategory">Product Category</th>
<td mat-cell *matCellDef="let row">{{ row.productCategory }}</td>
</ng-container>
<ng-container matColumnDef="stage">
<th mat-header-cell *matHeaderCellDef mat-sort-header="stage">Stage</th>
<td mat-cell *matCellDef="let row">{{ stageLabels[row.stage] ?? row.stage }}</td>
</ng-container>
<ng-container matColumnDef="duration">
<th mat-header-cell *matHeaderCellDef>Duration in Stage 3</th>
<td mat-cell *matCellDef="let row">{{ durationLabel(row) }}</td>
</ng-container>
<ng-container matColumnDef="securedFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="securedFloorPct">Secured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.securedFloorPct }}</td>
</ng-container>
<ng-container matColumnDef="unsecuredFloorPct">
<th mat-header-cell *matHeaderCellDef mat-sort-header="unsecuredFloorPct">Unsecured Floor (%)</th>
<td mat-cell *matCellDef="let row">{{ row.unsecuredFloorPct }}</td>
</ng-container>
<tr mat-header-row *matHeaderRowDef="displayedColumns"></tr>
<tr mat-row *matRowDef="let row; columns: displayedColumns" class="table-row"></tr>
</table>
<mat-paginator [pageSizeOptions]="[10, 25, 50, 100]" showFirstLastButtons></mat-paginator>
</div>
</div>
@@ -0,0 +1,32 @@
/**
* 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/.
*/
/** Angular Imports */
import { Injectable, inject } from '@angular/core';
/** rxjs Imports */
import { Observable } from 'rxjs';
/** Custom Services */
import { ProductsService } from '../../products.service';
/**
* Prudential Floor Master data resolver (RBI 2026 ECL framework, para 82). Read-only.
*/
@Injectable()
export class PrudentialFloorComponentsResolver {
private productsService = inject(ProductsService);
/**
* Returns the prudential floor master rows.
* @returns {Observable<any>}
*/
resolve(): Observable<any> {
return this.productsService.getPrudentialFloors();
}
}
@@ -0,0 +1,19 @@
/**
* 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/.
*/
@use '../../../shared/styles/list-layout' as *;
table {
width: 100%;
}
.pf-subtitle {
margin: 4px 0 0;
color: rgb(0 0 0 / 60%);
font-size: 0.875rem;
}
@@ -0,0 +1,118 @@
/**
* 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 { Component, OnInit, ViewChild, inject } from '@angular/core';
import { MatPaginator } from '@angular/material/paginator';
import { MatSort, MatSortHeader } from '@angular/material/sort';
import {
MatTableDataSource,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow
} from '@angular/material/table';
import { ActivatedRoute } from '@angular/router';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* RBI 2026 ECL framework: stage-wise prudential floor master (para 82), by loan product category.
* Read-only — the master is seeded from the RBI Directions, not created per bank, so there is no
* create/edit screen here (a Board-approved "tighten only" override is a follow-up, see backend
* PrudentialFloorsApiResource).
*/
@Component({
selector: 'mifosx-prudential-floor',
templateUrl: './prudential-floor.component.html',
styleUrls: ['./prudential-floor.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTable,
MatSort,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatSortHeader,
MatCellDef,
MatCell,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatPaginator
]
})
export class PrudentialFloorComponent implements OnInit {
private route = inject(ActivatedRoute);
prudentialFloorData: any[];
/** Columns to be displayed in the prudential floor table. */
displayedColumns: string[] = [
'productCategory',
'stage',
'duration',
'securedFloorPct',
'unsecuredFloorPct'
];
/** Data source for the prudential floor table. */
dataSource: MatTableDataSource<any>;
/** Paginator for the prudential floor table. */
@ViewChild(MatPaginator, { static: true }) paginator: MatPaginator;
/** Sorter for the prudential floor table. */
@ViewChild(MatSort, { static: true }) sort: MatSort;
readonly stageLabels: Record<number, string> = {
1: 'Stage 1',
2: 'Stage 2',
3: 'Stage 3'
};
constructor() {
this.route.data.subscribe((data: { prudentialFloors: any[] }) => {
this.prudentialFloorData = data.prudentialFloors;
});
}
ngOnInit(): void {
this.setDatasource();
}
/**
* Filters data in the prudential floor table based on passed value.
* @param {string} filterValue Value to filter data.
*/
applyFilter(filterValue: string) {
this.dataSource.filter = filterValue.trim().toLowerCase();
}
/** "0–365 days", "1461+ days" (open-ended) or "—" (Stage 1/2: not duration-banded). */
durationLabel(row: any): string {
if (row.durationFromDays == null) {
return '—';
}
if (row.durationToDays == null) {
return `${row.durationFromDays}+ days`;
}
return `${row.durationFromDays}–${row.durationToDays} days`;
}
/**
* Initializes the data source, paginator and sorter for the prudential floor table.
*/
setDatasource() {
this.dataSource = new MatTableDataSource(this.prudentialFloorData);
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
}
@@ -120,6 +120,8 @@ import { DelinquencyBucketComponent } from './manage-delinquency-buckets/delinqu
import { DelinquencyRangeComponent } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component';
import { DelinquencyBucketComponentsResolver } from './manage-delinquency-buckets/delinquency-bucket/delinquency-bucket.component.resolver';
import { DelinquencyRangeComponentsResolver } from './manage-delinquency-buckets/delinquency-range/delinquency-range.component.resolver';
import { PrudentialFloorComponent } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component';
import { PrudentialFloorComponentsResolver } from './manage-delinquency-buckets/prudential-floor/prudential-floor.component.resolver';
import { ViewRangeComponent } from './manage-delinquency-buckets/delinquency-range/view-range/view-range.component';
import { EditRangeComponent } from './manage-delinquency-buckets/delinquency-range/edit-range/edit-range.component';
import { CreateRangeComponent } from './manage-delinquency-buckets/delinquency-range/create-range/create-range.component';
@@ -224,6 +226,15 @@ const routes: Routes = [
},
{
path: 'edit',
component: CreateLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
loanProductAndTemplate: LoanProductAndTemplateResolver,
configurations: GlobalConfigurationsResolver
}
},
{
path: 'edit-legacy',
component: EditLoanProductComponent,
data: { title: 'Edit Loan Product', breadcrumb: 'Edit', routeParamBreadcrumb: false },
resolve: {
@@ -663,6 +674,14 @@ const routes: Routes = [
]
}
]
},
{
path: 'floor-master',
component: PrudentialFloorComponent,
data: { title: 'Prudential Floor Master', breadcrumb: 'Prudential Floor Master' },
resolve: {
prudentialFloors: PrudentialFloorComponentsResolver
}
}
]
},
@@ -1104,6 +1123,7 @@ const routes: Routes = [
CollateralTemplateResolver,
DelinquencyRangeComponentsResolver,
DelinquencyBucketComponentsResolver,
PrudentialFloorComponentsResolver,
CurrentProductsResolver,
CurrentProductsTemplateResolver,
CurrentProductResolver,
+41 -2
View File
@@ -12,7 +12,8 @@ import { HttpClient, HttpContext, HttpParams } from '@angular/common/http';
import { SILENT_HTTP_ERROR } from 'app/core/http/silent-error.token';
/** rxjs Imports */
import { Observable } from 'rxjs';
import { Observable, forkJoin, of } from 'rxjs';
import { catchError, map } from 'rxjs/operators';
/** Custom Services */
import { SettingsService } from 'app/settings/settings.service';
@@ -583,6 +584,17 @@ export class ProductsService {
return this.http.put(`/taxes/group/${taxGroupId}`, taxGroup);
}
/**
* RBI 2026 ECL framework: stage-wise prudential floor master, by product category. Read-only — seeded
* from the RBI Directions (para 82) rather than created per bank.
* @param {string} [productCategory] Optional filter.
* @returns {Observable<any>} Prudential Floor rows
*/
getPrudentialFloors(productCategory?: string): Observable<any> {
const params = productCategory ? { productCategory } : {};
return this.http.get('/prudentialfloors', { params });
}
/**
* @returns {Observable<any>} Delinquency Range data
*/
@@ -867,9 +879,36 @@ export class ProductsService {
}
/** Requests of one entity (e.g. DELINQUENCY_RANGE) still awaiting a checker. */
/**
* Pending requests for an entity: everything the user may approve (checker inbox) plus the requests the user
* submitted. The inbox alone hides a maker's own requests unless the maker also holds checker permission.
* Each entry carries canApprove, true only for those the user can actually approve.
*/
getPendingMakerCheckerByEntity(entityName: string): Observable<any> {
const params = new HttpParams().set('entityName', entityName);
return this.http.get('/makercheckers', { params, context: new HttpContext().set(SILENT_HTTP_ERROR, true) });
const context = new HttpContext().set(SILENT_HTTP_ERROR, true);
const toApprove$ = this.http
.get<any[]>('/makercheckers', { params, context })
.pipe(catchError(() => of([] as any[])));
const mine$ = this.http
.get<any[]>('/makercheckers/mine', { params, context })
.pipe(catchError(() => of([] as any[])));
return forkJoin([
toApprove$,
mine$
]).pipe(
map(
([
toApprove,
mine
]) => {
const byId = new Map<number, any>();
(mine ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: false }));
(toApprove ?? []).forEach((entry) => byId.set(entry.id, { ...entry, canApprove: true }));
return [...byId.values()];
}
)
);
}
/** Full maker-checker entry, including the submitted payload (commandAsJson), which the list call omits. */
@@ -63,9 +63,90 @@ export class ProductEditDiffDialogComponent implements OnInit {
'id',
'productId',
'savingsProductId',
'otherGlMappings'
'otherGlMappings',
// Loan / advance engine defaults and the locked type: not settings a reviewer decides on.
'amortizationType',
'interestType',
'interestCalculationPeriodType',
'transactionProcessingStrategyCode',
'daysInYearType',
'daysInMonthType',
'isInterestRecalculationEnabled',
'repaymentEvery',
'repaymentFrequencyType',
'interestRateFrequencyType',
'accountingRule',
'productTypeGroup',
'productTypeSubGroup'
]);
/** Plain names for the loan / advance fields that otherwise read as raw parameter names. */
private static readonly FRIENDLY_LABELS: Record<string, string> = {
minPrincipal: 'Minimum amount / limit',
principal: 'Default amount / limit',
maxPrincipal: 'Maximum amount / limit',
minNumberOfRepayments: 'Minimum tenure (months)',
numberOfRepayments: 'Default tenure (months)',
maxNumberOfRepayments: 'Maximum tenure (months)',
interestRatePerPeriod: 'Default interest rate (%)',
minInterestRatePerPeriod: 'Minimum interest rate (%)',
maxInterestRatePerPeriod: 'Maximum interest rate (%)',
overdueDaysForNPA: 'NPA after (days overdue)',
graceOnArrearsAgeing: 'Arrears grace (days)',
smaStartAfterMonths: 'SMA counting starts after (months)',
penalRate: 'Penal rate (%)',
margin: 'Margin (%)',
ltv: 'LTV (%)',
glAccount: 'Product GL (asset)',
registerParticular: 'Rate register row',
loanPortfolioAccountId: 'Loan portfolio GL',
fundSourceAccountId: 'Fund source GL',
interestOnLoanAccountId: 'Interest income GL',
incomeFromFeeAccountId: 'Fee income GL',
incomeFromPenaltyAccountId: 'Penal income GL',
writeOffAccountId: 'Write-off GL',
overpaymentLiabilityAccountId: 'Overpayment GL',
receivableInterestAccountId: 'Interest receivable GL',
undebitedInterestGlId: 'Interest suspense GL',
goldMaxLtv: 'Gold: maximum LTV cap (%)',
goldMinPurityCarat: 'Gold: minimum purity (carat)',
goldRateSource: 'Gold: valuation rate source',
goldMaxPerGram: 'Gold: max loan per gram (₹)',
goldMinNetWeight: 'Gold: minimum net weight (g)',
goldMaxNetWeightPerCustomer: 'Gold: max net weight per customer (g)',
goldAllowedItems: 'Gold: allowed items',
goldAppraiserRequired: 'Gold: appraiser required',
goldValuationCertificateRequired: 'Gold: valuation certificate required',
goldMaxValuationPerAppraiser: 'Gold: max valuation per appraiser (₹)',
goldPacketPrefix: 'Gold: packet number prefix',
goldRevalueOnNpa: 'Gold: revalue when loan turns NPA',
goldRevaluationChargeApplicable: 'Gold: charge customer for revaluation',
insuranceRequired: 'Insurance required',
goldAuctionNoticeDays: 'Gold: auction notice (days)',
goldPartialReleaseAllowed: 'Gold: partial release of ornaments',
goldThirdPartyAllowed: 'Gold: third-party gold allowed',
goldDisbursementMode: 'Gold: disbursement mode',
goldCashDisbursementLimit: 'Gold: cash disbursement limit (₹)',
goldLtvBreachAlert: 'Gold: LTV breach alert (%)',
goldAuctionReservePct: 'Gold: auction reserve price (%)',
goldRedeemBeforeAuction: 'Gold: customer may redeem before auction',
wrMaxLtv: 'Warehouse: maximum LTV cap (%)',
wrReceiptType: 'Warehouse: receipt type allowed',
wrWdraRegistered: 'Warehouse: WDRA-registered warehouse',
wrAssayCertificateRequired: 'Warehouse: assay certificate required',
wrTenureWithinReceiptValidity: 'Warehouse: tenure within receipt validity',
wrPledgeMarkedBeforeDisbursement: 'Warehouse: pledge marked before disbursement',
wrCommodityGroups: 'Warehouse: eligible commodity groups',
wrValuationPriceSource: 'Warehouse: valuation price source',
wrMaxLoanPerQuintal: 'Warehouse: max loan per quintal (₹)',
wrStockInspectionMonths: 'Warehouse: stock inspection (months)',
wrLtvBreachAlert: 'Warehouse: LTV breach alert (%)',
wrPartialReleaseAllowed: 'Warehouse: partial release of stock',
wrDisposalNoticeDays: 'Warehouse: disposal notice (days)',
wrDisposalReservePct: 'Warehouse: disposal reserve price (%)',
wrRedeemBeforeDisposal: 'Warehouse: customer may redeem before disposal'
};
/** The two reserved otherGlMappings entries a checker actually needs to see by name -- see
* AccountingProcessorHelper.SAVINGS_INTEREST_PAYABLE_MAPPING / SAVINGS_INTEREST_PAID_MAPPING. Any
* other entry in the assets/liabilities/expenses/income lists is a free-form "Other" row with no
@@ -84,6 +165,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
digitsAfterDecimal: 'currency.decimalPlaces',
inMultiplesOf: 'currency.inMultiplesOf',
taxGroupId: 'taxGroup.id',
delinquencyBucketId: 'delinquencyBucket.id',
// Enum fields the stored product exposes as "<name>Type": {id, value}", not "<name>TypeId" --
// without these the "current" side always read as blank, so these showed as changed on every
// edit even when the maker never touched them.
@@ -124,7 +206,13 @@ export class ProductEditDiffDialogComponent implements OnInit {
penaltiesReceivableAccountId: 'penaltyReceivableAccount',
interestPayableAccountId: 'interestPayableAccount',
interestReceivableAccountId: 'interestReceivableAccount',
escheatLiabilityId: 'escheatLiabilityAccount'
escheatLiabilityId: 'escheatLiabilityAccount',
// loan / advance products
fundSourceAccountId: 'fundSourceAccount',
loanPortfolioAccountId: 'loanPortfolioAccount',
interestOnLoanAccountId: 'interestOnLoanAccount',
overpaymentLiabilityAccountId: 'overpaymentLiabilityAccount',
receivableInterestAccountId: 'receivableInterestAccount'
};
private glAccountNamesById = new Map<string, string>();
@@ -143,7 +231,7 @@ export class ProductEditDiffDialogComponent implements OnInit {
.map((key) => ({ key, originalValue: this.originalValueFor(key, original) }))
.filter(({ key, originalValue }) => !sameValue(originalValue, proposed[key]))
.map(({ key, originalValue }) => ({
label: humanizeKey(key),
label: ProductEditDiffDialogComponent.FRIENDLY_LABELS[key] ?? humanizeKey(key),
original: this.displayValue(originalValue, key, original),
proposed: this.displayValue(proposed[key], key, original)
}))
+4
View File
@@ -127,6 +127,10 @@ export function normalizeValue(value: any): any {
if (typeof value === 'boolean') {
return value;
}
// A YES / NO select is saved as true / false: the same answer, so compare them as one.
if (typeof value === 'string' && /^(yes|no)$/i.test(value.trim())) {
return value.trim().toLowerCase() === 'yes';
}
const asNumber = Number(value);
return Number.isNaN(asNumber) ? value : asNumber;
}
+3 -1
View File
@@ -7,6 +7,8 @@
*/
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { withExtensions } from 'app/products/loan-products/loan-product-original.util';
import { ProductsService } from 'app/products/products.service';
import { ClientsService } from 'app/clients/clients.service';
@@ -59,7 +61,7 @@ const ENTITY_FETCHERS: Record<string, (id: string, s: EntityFetchServices) => Ob
SAVINGSPRODUCT: (id, s) => s.products.getSavingProduct(id, true),
CURRENTPRODUCT: (id, s) => s.products.getCurrentProduct(id, true),
SHAREPRODUCT: (id, s) => s.products.getShareProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true),
LOANPRODUCT: (id, s) => s.products.getLoanProduct(id, true).pipe(map((product: any) => withExtensions(product))),
FIXEDDEPOSITPRODUCT: (id, s) => s.products.getFixedDepositProductAndTemplate(id),
RECURRINGDEPOSITPRODUCT: (id, s) => s.products.getRecurringDepositProduct(id, true),
CHARGE: (id, s) => s.products.getCharge(id, true),
+2 -2
View File
@@ -9,8 +9,8 @@
/* tslint:disable */
export default {
'mifos_x': {
'version': '261012',
'hash': '6b0f218b'
'version': '261013',
'hash': 'd88f2a6eb'
},
'allow_switching_backend_instance': true
};