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:
+13
-3
@@ -48,7 +48,13 @@
|
||||
<mat-tab [disabled]="!hasProductType">
|
||||
<ng-template mat-tab-label>
|
||||
<i class="fas fa-shield-alt lp-tab-icon"></i>
|
||||
SECURITY & 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 & 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 & 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>
|
||||
|
||||
|
||||
+271
-23
@@ -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 } } };
|
||||
}
|
||||
|
||||
+19
-12
@@ -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>
|
||||
}
|
||||
|
||||
+20
-12
@@ -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,
|
||||
|
||||
+66
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+36
-5
@@ -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>
|
||||
|
||||
|
||||
+149
-1
@@ -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';
|
||||
}
|
||||
|
||||
+10
-3
@@ -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>
|
||||
|
||||
+5
-2
@@ -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]
|
||||
});
|
||||
|
||||
+19
-13
@@ -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
|
||||
|
||||
+42
-6
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-54
@@ -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">
|
||||
|
||||
+181
-46
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
+5
-3
@@ -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>
|
||||
|
||||
+12
@@ -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;
|
||||
|
||||
+5
-3
@@ -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">
|
||||
|
||||
+211
-5
@@ -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)
|
||||
)
|
||||
|
||||
+438
-9
@@ -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">
|
||||
|
||||
+17
@@ -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;
|
||||
}
|
||||
|
||||
+325
-36
@@ -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
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -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
|
||||
|
||||
+42
-1
@@ -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'];
|
||||
|
||||
+38
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user