From ec550b17988e2eeec7c5beed246c3270d85ab7d5 Mon Sep 17 00:00:00 2001 From: Sonali Likhitkar Date: Mon, 19 Oct 2026 10:54:36 +0530 Subject: [PATCH] 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 --- .../create-loan-product.component.html | 16 +- .../create-loan-product.component.ts | 294 +++++++++++- .../loan-product-original.util.ts | 53 +++ ...t-advance-limit-tenure-step.component.html | 31 +- ...uct-advance-limit-tenure-step.component.ts | 32 +- .../loan-product-details-step.component.ts | 66 +++ ...uct-loan-amount-tenure-step.component.html | 41 +- ...oduct-loan-amount-tenure-step.component.ts | 150 +++++- ...roduct-loan-prepayment-step.component.html | 13 +- ...-product-loan-prepayment-step.component.ts | 7 +- ...product-loan-repayment-step.component.html | 32 +- ...n-product-loan-repayment-step.component.ts | 48 +- ...-product-loan-security-step.component.html | 76 +-- ...an-product-loan-security-step.component.ts | 227 +++++++-- ...loan-product-penal-npa-step.component.html | 8 +- .../loan-product-penal-npa-step.component.ts | 12 + .../loan-product-preview-step.component.html | 8 +- .../loan-product-preview-step.component.ts | 216 ++++++++- ...-product-security-lien-step.component.html | 447 +++++++++++++++++- ...-product-security-lien-step.component.scss | 17 + ...an-product-security-lien-step.component.ts | 361 ++++++++++++-- .../general-tab/general-tab.component.html | 8 + .../general-tab/general-tab.component.ts | 43 +- .../product-edit-diff-dialog.component.ts | 39 +- 24 files changed, 2007 insertions(+), 238 deletions(-) diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html index 48a3a2532..32d88edd0 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.html +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.html @@ -48,7 +48,13 @@ - SECURITY & LIEN + {{ + advanceSubType === 'gold' + ? 'SECURITY & GOLD' + : advanceSubType === 'warehouse' + ? 'SECURITY & WAREHOUSE' + : 'SECURITY & LIEN' + }}
- +
@@ -124,7 +132,9 @@ AMOUNT & TENURE
- +
diff --git a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts index 2afcf2f75..db620b95c 100644 --- a/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts +++ b/src/app/products/loan-products/create-loan-product/create-loan-product.component.ts @@ -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), diff --git a/src/app/products/loan-products/loan-product-original.util.ts b/src/app/products/loan-products/loan-product-original.util.ts index eacb920b1..6890a5c10 100644 --- a/src/app/products/loan-products/loan-product-original.util.ts +++ b/src/app/products/loan-products/loan-product-original.util.ts @@ -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 = { + 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 } } }; +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html index b14b16918..362964072 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.html @@ -61,12 +61,12 @@ Minimum Tenure (months) - @if (isFdTenureLocked) { - FD policy: 12–15 months only + @if (tenureBand; as band) { + {{ band.label }} policy: {{ band.min }}–{{ band.max }} months only } - @if (isFdTenureLocked) { - Must be between 12 and 15 months + @if (tenureBand; as band) { + Must be between {{ band.min }} and {{ band.max }} months } @else { Minimum Tenure is required } @@ -75,24 +75,31 @@ Default Tenure (months) - @if (isFdTenureLocked) { - Between 12 and 15 + @if (tenureBand; as band) { + Between {{ band.min }} and {{ band.max }} } - @if (isFdTenureLocked) { - Must be between 12 and 15 months + @if (tenureBand; as band) { + Must be between {{ band.min }} and {{ band.max }} months } Maximum Tenure (months) - @if (isFdTenureLocked) { - FD policy: max 15 months (also capped at deposit maturity) + @if (tenureBand; as band) { + + {{ band.label }} policy: max {{ band.max }} months + @if (band.label === 'FD') { + (also capped at deposit maturity) + } + } @else { Also capped at the deposit's maturity (Lien rule) } - @if (isFdTenureLocked) { - Must be between 12 and 15 months + @if (tenureBand; as band) { + Must be between {{ band.min }} and {{ band.max }} months } @else { Maximum Tenure is required } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts index ddb60f62b..c3ed357e1 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component.ts @@ -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 = { + 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, diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts index e2f9631f6..504397ed0 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-details-step/loan-product-details-step.component.ts @@ -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 = { + 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 = { + 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 }); + } }); } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html index 72b1d72d7..7e95d88c0 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.html @@ -44,18 +44,38 @@
Minimum Tenure (months) - - Minimum Tenure is required + + @if (band; as b) { + {{ b.label }}: {{ b.minTenure }}–{{ b.maxTenure }} months + } + + @if (band; as b) { + Must be between {{ b.minTenure }} and {{ b.maxTenure }} months + } @else { + Minimum Tenure is required + } + Default Tenure (months) - + + @if (band; as b) { + Must be between {{ b.minTenure }} and {{ b.maxTenure }} months + } Maximum Tenure (months) - + Age at maturity cap comes from Borrower Eligibility below - Maximum Tenure is required + + @if (band; as b) { + Must be between {{ b.minTenure }} and {{ b.maxTenure }} months + } @else { + Maximum Tenure is required + } +
@@ -90,6 +110,17 @@ Maximum Loans per Customer + + Income Multiple (× net monthly salary) + + Loan up to this many times the net salary. Policy: personal loan 10× + + +
+
+ Applicant must be a member of the bank + Guarantors must be members of the bank +
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts index ff547810f..e72d3e8c4 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-amount-tenure-step/loan-product-loan-amount-tenure-step.component.ts @@ -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 = { + 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'; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html index b17ca654c..e39dc8e71 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.html @@ -63,8 +63,16 @@
Individual · floating rate (%) - - Nil as per RBI + + Nil as per RBI. Cannot be changed Individual · fixed rate (%) @@ -132,7 +140,6 @@
- Release collateral and lien on closure Top-up loan on the same account Allow balance transfer (takeover) out
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts index e5c6487e8..1eb51ad99 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-prepayment-step/loan-product-loan-prepayment-step.component.ts @@ -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] }); diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html index 4972737f7..82550fae1 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.html @@ -49,6 +49,7 @@ Minimum Days to First Repayment + Policy: instalments start from the 2nd month after the loan month EMI Due Day @@ -69,6 +70,7 @@ Moratorium / Grace (months) + Interest and EMI fall due only after the moratorium ends (RBI) Interest during Moratorium @@ -98,14 +100,16 @@ } - - Linked Account Type - - @for (opt of linkedAccountOptions; track opt.id) { - {{ opt.label }} - } - - + @if (isAutoDebit) { + + Linked Account Type + + @for (opt of linkedAccountOptions; track opt.id) { + {{ opt.label }} + } + + + } PDC Collection @@ -115,11 +119,13 @@
-
- - Mandate registration is required before the first disbursement - -
+ @if (isAutoDebit) { +
+ + Mandate registration is required before the first disbursement + +
+ }
Cheques are used here to repay the EMI as post-dated cheques, not to withdraw from the loan. A cheque book is diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts index 938eb97ec..84beb3db4 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-repayment-step/loan-product-loan-repayment-step.component.ts @@ -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 = { + 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; } } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html index 6650b8f5a..9c3d73bfe 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.html @@ -28,6 +28,9 @@ Margin (%) + @if (marginHint) { + {{ marginHint }} + } Margin must be between 0 and 100 @@ -38,40 +41,6 @@
-
-
- -

Collateral Rules by Security Type

- Which security this product may accept -
- - - - - - - - - - - - - @for (rule of collateralRules; track rule.security) { - - - - - - - - - } - -
SecurityAllowedMax LTV %ValuationInsuranceCharge / lien
{{ rule.security }} - {{ rule.allowed ? 'Yes' : 'No' }} - {{ rule.ltv }}{{ rule.valuation }}{{ rule.insurance }}{{ rule.charge }}
-
-
@@ -90,14 +59,16 @@ } - - Third-party Security - - @for (opt of thirdPartyOptions; track opt.id) { - {{ opt.label }} - } - - + @if (isSecured) { + + Third-party Security + + @for (opt of thirdPartyOptions; track opt.id) { + {{ opt.label }} + } + + + }
@@ -107,18 +78,15 @@

Conditions

-
- - Legal opinion / title search mandatory before sanction - - - Property insurance mandatory for the whole tenure - - Register charge (CERSAI) before disbursement - Release security automatically on closure - Allow multiple securities for one loan - Allow third-party deposit as security -
+ @if (shownConditions.length > 0) { +
+ @for (rule of shownConditions; track rule.control) { + {{ rule.label }} + } +
+ } @else { +
This loan is unsecured, so it carries no security conditions.
+ }
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts index 6cc263c42..4dd2099b5 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-loan-security-step/loan-product-loan-security-step.component.ts @@ -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 = { + 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 = { - 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 = { + 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 }; } diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html index d0981d2a6..8f486c282 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.html @@ -141,9 +141,11 @@ Upgrade from NPA only when all arrears are cleared - - Keep standard while the pledged deposit fully covers the dues - + @if (isDepositBacked) { + + Keep standard while the pledged deposit fully covers the dues + + }
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts index c3da01979..c9833c139 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component.ts @@ -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; diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html index e2b67b3eb..fd8cbc294 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.html @@ -7,9 +7,11 @@ -->
-

- Check the details below. Use Submit at the bottom to send the product for approval. -

+ @if (!readOnly) { +

+ Check the details below. Use Submit at the bottom to send the product for approval. +

+ } @for (section of sections; track section.title) {
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts index fbbb353aa..4b5531385 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component.ts @@ -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 + )[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 + )[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) ) diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html index c2410aea5..406b62380 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.html @@ -27,15 +27,29 @@ From Sub Type — not chosen again - - Eligible Amount Basis - - @for (opt of eligibleAmountBasisOptions; track opt.id) { - {{ opt.label }} - } - - Eligible Amount Basis is required - + @if (isGold || isWarehouse) { + + Eligible Amount Basis + + + @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 + } + + + } @else { + + Eligible Amount Basis + + @for (opt of eligibleAmountBasisOptions; track opt.id) { + {{ opt.label }} + } + + Eligible Amount Basis is required + + } Margin (%) @@ -53,9 +67,424 @@ = 100 − Margin + + @if (isGold) { + + Maximum LTV Cap (%) + + No account may be sanctioned above this LTV (RBI ceiling 75%) + @if (loanProductSecurityLienForm.get('goldMaxLtv').hasError('required')) { + Maximum LTV Cap is required + } @else { + Must be between 1 and 100 + } + + } + + @if (isWarehouse) { + + Maximum LTV Cap (%) + + No account may be sanctioned above this LTV + @if (loanProductSecurityLienForm.get('wrMaxLtv').hasError('required')) { + Maximum LTV Cap is required + } @else { + Must be between 1 and 100 + } + + }
+ @if ((isGold || isWarehouse) && loanProductSecurityLienForm.hasError('ltvAboveCap')) { +
+ + LTV (100 − Margin) is above the Maximum LTV Cap. Raise the margin or the cap. +
+ }
+ @if (isGold) { +
+
+ +

Gold Details

+ Pledged ornaments — no lien marking +
+ +
+

Valuation basis

+
+ + Minimum Purity (carat) + + @for (carat of goldPurityOptions; track carat) { + {{ carat }} carat + } + + Ornaments below this purity are not accepted + + + + Valuation Rate Source + + @for (opt of goldRateSourceOptions; track opt.id) { + {{ opt.label }} + } + + + + + Allowed Items + + @for (opt of goldAllowedItemsOptions; track opt.id) { + {{ opt.label }} + } + + + + + Max Loan per Gram (₹) + + Optional bank cap on the gold rate + Must be above 0 + + + + Min Net Weight (g) + + Optional + + + + Max Net Weight per Customer (g) + + Optional + +
+ @if (loanProductSecurityLienForm.hasError('netWeightRange')) { +
+ + Minimum net weight cannot exceed the maximum per customer. +
+ } +
+ +
+

Valuation by appraiser

+
+ + Appraiser Required + + + + + Valuation Certificate Required + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + + Max Valuation per Appraiser (₹) + + Optional default limit for one appraiser + Must be above 0 + +
+
+ +
+

Safe custody

+
+ + Packet No. Prefix + + The packet number itself is set per account + + + + Insurance Required + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + +
+
+ +
+

Revaluation

+
+ + Revalue the gold when the loan turns NPA (short-term loans are not revalued on a schedule) + + + Charge the customer for revaluation (set the fee on the Charges step) + +
+
+ +
+

Loan servicing rules

+
+ + Partial release of ornaments allowed on part payment (LTV must stay within the cap) + + + Third-party gold allowed (relative's gold, consent letter required) + +
+
+ +
+

Disbursement and monitoring

+
+ + Disbursement Mode + + @for (opt of goldDisbursementModeOptions; track opt.id) { + {{ opt.label }} + } + + + + @if (loanProductSecurityLienForm.get('goldDisbursementMode').value === 'account_credit_cash_limit') { + + Cash Disbursement Limit (₹) + + Cash above this is not allowed (Income Tax Sec 269SS) + Must be between 0 and {{ goldCashLimitMax }} + + } + + + LTV Breach Alert (%) + + Flag the account when the live LTV (after a gold rate fall) crosses this level + Required, between 1 and 100 + +
+ @if (loanProductSecurityLienForm.hasError('alertBelowCap')) { +
+ + LTV Breach Alert cannot be below the Maximum LTV Cap. +
+ } +
+ +
+

Auction and recovery

+
+ + Auction Notice Period (days) + + Notice given before an auction after NPA + Auction Notice Period is required + + + + Auction Reserve Price (% of market value) + + Gold is not sold below this share of the market value + Required, between 1 and 100 + +
+
+ + Customer may redeem (clear dues and take the gold) any time before the auction. Always on + +
+
+
+ +
+ + Packet number, item list, weights, carat, appraiser and valuation date are captured per account at + Loan Account Creation, not on the product. +
+ } + + @if (isWarehouse) { +
+
+ +

Warehouse Receipt Details

+ Pledged commodity — no lien marking +
+ +
+

Receipt

+
+ + Receipt Type Allowed + + @for (opt of wrReceiptTypeOptions; track opt.id) { + {{ opt.label }} + } + + e-NWR is pledge-marked online in the repository + + + + Warehouse WDRA-registered + + + + + Quality / Assay Certificate + + +
+
+ + Tenure cannot exceed the receipt's validity date + + + Receipt must be pledge-marked in the repository before disbursement + +
+
+ +
+

Commodity and valuation

+
+ + Eligible Commodity Groups + + @for (opt of wrCommodityGroupOptions; track opt.id) { + {{ opt.label }} + } + + Only receipts for these commodities can be pledged + Pick at least one commodity group + + + + Valuation Price Source + + @for (opt of wrValuationSourceOptions; track opt.id) { + {{ opt.label }} + } + + + + + Max Loan per Quintal (₹) + + Optional bank cap on the price + Must be above 0 + +
+
+ +
+

Insurance and inspection

+
+ + Insurance Required + + @for (opt of yesNoOptions; track opt) { + {{ opt }} + } + + + + + Stock Inspection Every (months) + + Physical check that the stock is in the warehouse + Required, at least 1 + + + + LTV Breach Alert (%) + + Flag the account when a price fall pushes the live LTV above this + Required, between 1 and 100 + +
+ @if (loanProductSecurityLienForm.hasError('alertBelowCap')) { +
+ + LTV Breach Alert cannot be below the Maximum LTV Cap. +
+ } +
+ +
+

Loan servicing rules

+
+ + Partial release of stock allowed on part payment (LTV must stay within the cap) + +
+
+ +
+

Disposal and recovery

+
+ + Disposal Notice Period (days) + + Notice given before the stock is sold after NPA + Disposal Notice Period is required + + + + Disposal Reserve Price (% of market value) + + Stock is not sold below this share of the market value + Required, between 1 and 100 + +
+
+ + Customer may redeem (clear dues and take the stock) any time before the disposal. Always on + +
+
+
+ +
+ + Commodity, quantity, grade, warehouse, receipt number and validity, repository pledge reference and the market + price on the sanction date are captured per account at Loan Account Creation, not on the + product. +
+ } + @if (isLienSubType) {
diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss index 7d57dfc5e..a41a12c1d 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.scss @@ -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; +} diff --git a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts index 318cb7292..2378f686f 100644 --- a/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts +++ b/src/app/products/loan-products/loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component.ts @@ -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 | null { + const gold = this.isGold; + if (!gold && !this.isWarehouse) { + return null; + } + const errors: Record = {}; + 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 + } }; } } diff --git a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html index 91b2336e0..bf34e32cb 100644 --- a/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html +++ b/src/app/products/loan-products/view-loan-product/general-tab/general-tab.component.html @@ -48,6 +48,14 @@
+ @if (advanceReview) { + + } +
{ @@ -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']; diff --git a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts index 57d5c43b4..bc41c78e8 100644 --- a/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts +++ b/src/app/tasks/checker-inbox-and-tasks-tabs/pending-savings-products/product-edit-diff-dialog/product-edit-diff-dialog.component.ts @@ -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