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>
This commit is contained in:
2026-10-19 10:54:36 +05:30
co-authored by Claude Sonnet 5.5
parent 47785f59bc
commit ec550b1798
24 changed files with 2007 additions and 238 deletions
@@ -48,7 +48,13 @@
<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
@@ -114,7 +120,9 @@
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>
@@ -124,7 +132,9 @@
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>
@@ -182,9 +182,10 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
ngOnInit() {
this.accountingRuleData = this.accounting.getAccountingRulesForLoans();
this.buildAdvancedPaymentAllocation();
// Only Advances products are filled into the curated tabs so far. A product made before the type was saved, or a
// Loans A/C (EMI) product, uses the old editor instead of an empty form.
if (this.isEdit && this.loanProductsTemplate?.advanceExtensions?.productTypeGroup !== 'advances') {
// 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 });
}
}
@@ -208,14 +209,163 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
this.loanProductPenalNpaStep &&
(!this.isOverdraftFacility || this.loanProductChequeBookStep)
)
: false;
: 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;
}
this.populateAdvance();
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. */
@@ -265,7 +415,39 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
allowRenewalUnderLien: e.lienAllowRenewalUnderLien,
lienCertificateRequired: e.lienCertificateRequired,
lienCertificatePrefix: e.lienCertificatePrefix,
printOnDisbursement: e.printCertificateOnDisbursement
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, {
@@ -348,6 +530,12 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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() {
@@ -848,10 +1036,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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.
npaOutOfOrderBasis: pn.npaOutOfOrderBasis || undefined,
// 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,
@@ -870,6 +1057,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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,
@@ -907,7 +1097,6 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
autoCloseOnZeroBalance: pp.autoCloseOnZeroBalance,
nocLetter: pp.nocLetter,
restructuring: pp.restructuring,
releaseCollateralOnClosure: pp.releaseCollateralOnClosure,
topUpAllowed: pp.topUpAllowed,
balanceTransferOutAllowed: pp.balanceTransferOutAllowed
};
@@ -929,6 +1118,9 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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;
@@ -1004,10 +1196,62 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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),
@@ -1039,14 +1283,18 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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,
@@ -1059,7 +1307,7 @@ export class CreateLoanProductComponent implements OnInit, AfterViewInit {
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),
@@ -22,5 +22,58 @@ export function withExtensions(product: any): any {
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 } } };
}
@@ -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,24 +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>
@if (tenureBand; as band) {
<mat-hint>Between {{ band.min }} and {{ band.max }}</mat-hint>
}
@if (isFdTenureLocked) {
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
@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,
@@ -248,6 +248,72 @@ 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 });
}
});
}
@@ -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,7 +28,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatRadioButton
]
})
export class LoanProductLoanRepaymentStepComponent {
export class LoanProductLoanRepaymentStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
loanProductLoanRepaymentForm: UntypedFormGroup;
@@ -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">
@@ -16,27 +16,139 @@ interface SecurityProfile {
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> = {
personal: { securityType: 'None (unsecured)', securityMandatory: 'no', margin: 0, guarantorsRequired: 0 },
education: { securityType: 'None (unsecured)', securityMandatory: 'no', margin: 0, guarantorsRequired: 0 },
// 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: 1
guarantorsRequired: 0,
coApplicant: 'optional',
thirdPartySecurity: 'no'
},
vehicle: { securityType: 'Vehicle (hypothecation)', securityMandatory: 'yes', margin: 15, guarantorsRequired: 0 },
business: {
securityType: 'Immovable property / business assets',
securityMandatory: 'yes',
margin: 25,
guarantorsRequired: 1
guarantorsRequired: 1,
coApplicant: 'joint_property',
thirdPartySecurity: 'consent'
},
gold_emi: { securityType: 'Gold ornaments (pledge)', securityMandatory: 'yes', margin: 25, guarantorsRequired: 0 },
term: { securityType: 'Immovable property (mortgage)', securityMandatory: 'yes', margin: 20, guarantorsRequired: 1 }
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'
}
};
/**
@@ -62,6 +174,39 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
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' }
@@ -78,44 +223,6 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
{ 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)'],
@@ -164,6 +271,26 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
.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. */
@@ -173,8 +300,16 @@ export class LoanProductLoanSecurityStepComponent implements OnInit {
}
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
};
}
@@ -141,9 +141,11 @@
<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>
@@ -50,6 +50,16 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
/** 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'
@@ -94,6 +104,7 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
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)' }
@@ -230,6 +241,7 @@ export class LoanProductPenalNpaStepComponent implements OnInit {
dds: 'BANK_DEPOSIT',
lic: 'LIFE_INSURANCE',
gold: 'GOLD',
warehouse: 'COMMODITY',
gold_emi: 'GOLD'
};
const collateral = subType ? collateralBySubType[subType] : undefined;
@@ -7,9 +7,11 @@
-->
<div class="rv">
<p class="rv__intro">
Check the details below. Use <strong>Submit</strong> at the bottom to send the product for approval.
</p>
@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">
@@ -35,6 +35,8 @@ export class LoanProductPreviewStepComponent {
@Input() accountingRuleData: any;
@Input() loanProduct: any;
@Input() isValid = true;
/** True on the product View page: the reader without the "submit for approval" wording. */
@Input() readOnly = false;
@Output() submitEvent = new EventEmitter();
/** Keys shown in a section above; everything else non-empty goes to "Other settings". */
@@ -95,6 +97,11 @@ export class LoanProductPreviewStepComponent {
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[] = [
{
@@ -123,8 +130,20 @@ export class LoanProductPreviewStepComponent {
row('Minimum tenure', 'minNumberOfRepayments', months),
row('Default tenure', 'numberOfRepayments', months),
row('Maximum tenure', 'maxNumberOfRepayments', months),
row('Repayment type', 'repaymentType'),
row('Appropriation order', 'appropriationOrder')
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)
},
{
@@ -147,7 +166,20 @@ export class LoanProductPreviewStepComponent {
icon: 'fa-shield-halved',
rows: [
row('Security type', 'securityType'),
row('Eligible amount basis', 'eligibleAmountBasis'),
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'),
@@ -162,6 +194,173 @@ export class LoanProductPreviewStepComponent {
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',
@@ -191,6 +390,11 @@ export class LoanProductPreviewStepComponent {
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',
@@ -213,8 +417,10 @@ export class LoanProductPreviewStepComponent {
this.shown.add('productTypeSubGroup');
this.cachedSections = sections;
this.cachedOther = Object.keys(p)
.filter((key) => !this.shown.has(key) && !this.hidden.has(key))
// 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)
)
@@ -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' },
@@ -120,6 +176,16 @@ 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';
@@ -134,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);
@@ -149,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. */
@@ -206,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
}
};
}
}
@@ -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'];
@@ -107,7 +107,44 @@ export class ProductEditDiffDialogComponent implements OnInit {
writeOffAccountId: 'Write-off GL',
overpaymentLiabilityAccountId: 'Overpayment GL',
receivableInterestAccountId: 'Interest receivable GL',
undebitedInterestGlId: 'Interest suspense 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