feat(loan-products): add advance loan product stepper steps

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Sonali Likhitkar
2026-09-29 18:06:08 +05:30
co-authored by Claude Sonnet 5.5
parent 3f59cc6b91
commit 283ab2f733
25 changed files with 3934 additions and 1530 deletions
@@ -42,19 +42,94 @@
</div> </div>
</mat-tab> </mat-tab>
<mat-tab> @if (isAdvanceProduct) {
<ng-template mat-tab-label> <mat-tab>
<i class="fas fa-cog lp-tab-icon"></i> <ng-template mat-tab-label>
{{ 'labels.inputs.SETTINGS' | translate }} <i class="fas fa-shield-alt lp-tab-icon"></i>
</ng-template> SECURITY &amp; LIEN
<div class="lp-tab-content"> </ng-template>
<mifosx-loan-product-settings-step <div class="lp-tab-content">
[loanProductsTemplate]="loanProductsTemplate" <mifosx-loan-product-security-lien-step
[isLinkedToFloatingInterestRates]="loanProductTermsForm.get('isLinkedToFloatingInterestRates')" [loanProductsTemplate]="loanProductsTemplate"
(advancePaymentStrategy)="advancePaymentStrategy($event)" [productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
></mifosx-loan-product-settings-step> [productTypeSubGroup]="loanProductDetailsForm.get('productTypeSubGroup')"
</div> ></mifosx-loan-product-security-lien-step>
</mat-tab> </div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-sliders-h lp-tab-icon"></i>
LIMIT &amp; TENURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-limit-tenure-step
[productSubType]="advanceSubType"
></mifosx-loan-product-advance-limit-tenure-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-percent lp-tab-icon"></i>
INTEREST
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-advance-interest-step></mifosx-loan-product-advance-interest-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-triangle-exclamation lp-tab-icon"></i>
PENAL &amp; NPA
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-penal-npa-step
[loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-penal-npa-step>
</div>
</mat-tab>
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-arrows-rotate lp-tab-icon"></i>
RENEWAL &amp; CLOSURE
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-renewal-closure-step></mifosx-loan-product-renewal-closure-step>
</div>
</mat-tab>
@if (isOverdraftFacility) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-money-check lp-tab-icon"></i>
CHEQUE BOOK
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-cheque-book-step></mifosx-loan-product-cheque-book-step>
</div>
</mat-tab>
}
}
@if (!isAdvanceProduct) {
<mat-tab>
<ng-template mat-tab-label>
<i class="fas fa-cog lp-tab-icon"></i>
{{ 'labels.inputs.SETTINGS' | translate }}
</ng-template>
<div class="lp-tab-content">
<mifosx-loan-product-settings-step
[loanProductsTemplate]="loanProductsTemplate"
[isLinkedToFloatingInterestRates]="loanProductTermsForm?.get('isLinkedToFloatingInterestRates')"
[productTypeGroup]="loanProductDetailsForm.get('productTypeGroup')"
(advancePaymentStrategy)="advancePaymentStrategy($event)"
></mifosx-loan-product-settings-step>
</div>
</mat-tab>
}
@if (isAdvancedPaymentStrategy) { @if (isAdvancedPaymentStrategy) {
<mat-tab> <mat-tab>
@@ -90,15 +165,19 @@
</mat-tab> </mat-tab>
} }
<mat-tab> @if (!isAdvanceProduct) {
<ng-template mat-tab-label> <mat-tab>
<i class="fas fa-sliders-h lp-tab-icon"></i> <ng-template mat-tab-label>
{{ 'labels.inputs.TERMS' | translate }} <i class="fas fa-sliders-h lp-tab-icon"></i>
</ng-template> {{ 'labels.inputs.TERMS' | translate }}
<div class="lp-tab-content"> </ng-template>
<mifosx-loan-product-terms-step [loanProductsTemplate]="loanProductsTemplate"></mifosx-loan-product-terms-step> <div class="lp-tab-content">
</div> <mifosx-loan-product-terms-step
</mat-tab> [loanProductsTemplate]="loanProductsTemplate"
></mifosx-loan-product-terms-step>
</div>
</mat-tab>
}
<mat-tab> <mat-tab>
<ng-template mat-tab-label> <ng-template mat-tab-label>
@@ -109,7 +188,7 @@
<mifosx-loan-product-charges-step <mifosx-loan-product-charges-step
[loanProductsTemplate]="loanProductsTemplate" [loanProductsTemplate]="loanProductsTemplate"
[currencyCode]="loanProductCurrencyForm.get('currencyCode')" [currencyCode]="loanProductCurrencyForm.get('currencyCode')"
[multiDisburseLoan]="loanProductSettingsForm.get('multiDisburseLoan')" [multiDisburseLoan]="loanProductSettingsForm?.get('multiDisburseLoan') ?? multiDisburseFallback"
></mifosx-loan-product-charges-step> ></mifosx-loan-product-charges-step>
</div> </div>
</mat-tab> </mat-tab>
@@ -13,6 +13,12 @@ import { ActivatedRoute, Router } from '@angular/router';
/** Custom Components */ /** Custom Components */
import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component'; import { LoanProductDetailsStepComponent } from '../loan-product-stepper/loan-product-details-step/loan-product-details-step.component';
import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component'; import { LoanProductCurrencyStepComponent } from '../loan-product-stepper/loan-product-currency-step/loan-product-currency-step.component';
import { LoanProductSecurityLienStepComponent } from '../loan-product-stepper/loan-product-security-lien-step/loan-product-security-lien-step.component';
import { LoanProductAdvanceLimitTenureStepComponent } from '../loan-product-stepper/loan-product-advance-limit-tenure-step/loan-product-advance-limit-tenure-step.component';
import { LoanProductAdvanceInterestStepComponent } from '../loan-product-stepper/loan-product-advance-interest-step/loan-product-advance-interest-step.component';
import { LoanProductPenalNpaStepComponent } from '../loan-product-stepper/loan-product-penal-npa-step/loan-product-penal-npa-step.component';
import { LoanProductRenewalClosureStepComponent } from '../loan-product-stepper/loan-product-renewal-closure-step/loan-product-renewal-closure-step.component';
import { LoanProductChequeBookStepComponent } from '../loan-product-stepper/loan-product-cheque-book-step/loan-product-cheque-book-step.component';
import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component'; import { LoanProductTermsStepComponent } from '../loan-product-stepper/loan-product-terms-step/loan-product-terms-step.component';
import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component'; import { LoanProductSettingsStepComponent } from '../loan-product-stepper/loan-product-settings-step/loan-product-settings-step.component';
import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component'; import { LoanProductChargesStepComponent } from '../loan-product-stepper/loan-product-charges-step/loan-product-charges-step.component';
@@ -34,7 +40,7 @@ import {
import { Accounting } from 'app/core/utils/accounting'; import { Accounting } from 'app/core/utils/accounting';
import { StringEnumOptionData } from '../../../shared/models/option-data.model'; import { StringEnumOptionData } from '../../../shared/models/option-data.model';
import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component'; import { LoanProductDeferredIncomeRecognitionStepComponent } from '../loan-product-stepper/loan-product-capitalized-income-step/loan-product-deferred-income-recognition-step.component';
import { UntypedFormGroup } from '@angular/forms'; import { UntypedFormControl, UntypedFormGroup } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component'; import { LoanProductPaymentStrategyStepComponent } from '../loan-product-stepper/loan-product-payment-strategy-step/loan-product-payment-strategy-step.component';
import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component'; import { LoanProductPreviewStepComponent } from '../loan-product-stepper/loan-product-preview-step/loan-product-preview-step.component';
@@ -51,6 +57,12 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FaIconComponent, FaIconComponent,
LoanProductDetailsStepComponent, LoanProductDetailsStepComponent,
LoanProductCurrencyStepComponent, LoanProductCurrencyStepComponent,
LoanProductSecurityLienStepComponent,
LoanProductAdvanceLimitTenureStepComponent,
LoanProductAdvanceInterestStepComponent,
LoanProductPenalNpaStepComponent,
LoanProductRenewalClosureStepComponent,
LoanProductChequeBookStepComponent,
LoanProductSettingsStepComponent, LoanProductSettingsStepComponent,
LoanProductInterestRefundStepComponent, LoanProductInterestRefundStepComponent,
LoanProductPaymentStrategyStepComponent, LoanProductPaymentStrategyStepComponent,
@@ -73,12 +85,27 @@ export class CreateLoanProductComponent implements OnInit {
@ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent; @ViewChild(LoanProductDetailsStepComponent, { static: true }) loanProductDetailsStep: LoanProductDetailsStepComponent;
@ViewChild(LoanProductCurrencyStepComponent, { static: true }) @ViewChild(LoanProductCurrencyStepComponent, { static: true })
loanProductCurrencyStep: LoanProductCurrencyStepComponent; loanProductCurrencyStep: LoanProductCurrencyStepComponent;
// Conditional tabs (Advances A/C only): static false — resolved after the @if renders them.
@ViewChild(LoanProductSecurityLienStepComponent)
loanProductSecurityLienStep: LoanProductSecurityLienStepComponent;
@ViewChild(LoanProductAdvanceLimitTenureStepComponent)
loanProductAdvanceLimitTenureStep: LoanProductAdvanceLimitTenureStepComponent;
@ViewChild(LoanProductAdvanceInterestStepComponent)
loanProductAdvanceInterestStep: LoanProductAdvanceInterestStepComponent;
@ViewChild(LoanProductPenalNpaStepComponent) loanProductPenalNpaStep: LoanProductPenalNpaStepComponent;
@ViewChild(LoanProductRenewalClosureStepComponent)
loanProductRenewalClosureStep: LoanProductRenewalClosureStepComponent;
@ViewChild(LoanProductChequeBookStepComponent) loanProductChequeBookStep: LoanProductChequeBookStepComponent;
/** Charges step needs a real control; Settings is absent on the Advances path, so fall back to this. */
readonly multiDisburseFallback = new UntypedFormControl(false);
@ViewChild(LoanProductInterestRefundStepComponent, { static: true }) @ViewChild(LoanProductInterestRefundStepComponent, { static: true })
loanProductInterestRefundStep: LoanProductInterestRefundStepComponent; loanProductInterestRefundStep: LoanProductInterestRefundStepComponent;
@ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true }) @ViewChild(LoanProductDeferredIncomeRecognitionStepComponent, { static: true })
loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent; loanProductDeferredIncomeRecognitionStep: LoanProductDeferredIncomeRecognitionStepComponent;
@ViewChild(LoanProductTermsStepComponent, { static: true }) loanProductTermsStep: LoanProductTermsStepComponent; // Terms & Settings tabs are now conditional (Loans path only), so resolve them non-statically.
@ViewChild(LoanProductSettingsStepComponent, { static: true }) @ViewChild(LoanProductTermsStepComponent) loanProductTermsStep: LoanProductTermsStepComponent;
@ViewChild(LoanProductSettingsStepComponent)
loanProductSettingsStep: LoanProductSettingsStepComponent; loanProductSettingsStep: LoanProductSettingsStepComponent;
@ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent; @ViewChild(LoanProductChargesStepComponent, { static: true }) loanProductChargesStep: LoanProductChargesStepComponent;
@ViewChild(LoanProductAccountingStepComponent, { static: true }) @ViewChild(LoanProductAccountingStepComponent, { static: true })
@@ -143,7 +170,18 @@ export class CreateLoanProductComponent implements OnInit {
} }
get loanProductTermsForm() { get loanProductTermsForm() {
return this.loanProductTermsStep.loanProductTermsForm; // Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductTermsStep?.loanProductTermsForm;
}
/** True once the Advance Limit & Tenure step reports an Overdraft (running) facility. */
get isOverdraftFacility(): boolean {
return this.isAdvanceProduct && !!this.loanProductAdvanceLimitTenureStep?.isOverdraft;
}
/** Sub type chosen on the Details step; drives the FD 12–15 month tenure policy downstream. */
get advanceSubType(): string {
return this.loanProductDetailsStep?.loanProductDetailsForm?.get('productTypeSubGroup')?.value ?? '';
} }
advancePaymentStrategy(value: string) { advancePaymentStrategy(value: string) {
@@ -231,47 +269,71 @@ export class CreateLoanProductComponent implements OnInit {
} }
get loanProductSettingsForm() { get loanProductSettingsForm() {
return this.loanProductSettingsStep.loanProductSettingsForm; // Absent on the Advances path (tab hidden), so guard for callers.
return this.loanProductSettingsStep?.loanProductSettingsForm;
} }
get loanProductAccountingForm() { get loanProductAccountingForm() {
return this.loanProductAccountingStep.loanProductAccountingForm; return this.loanProductAccountingStep.loanProductAccountingForm;
} }
/** The Details step's chosen product type; drives the Advances-only tabs. */
get isAdvanceProduct(): boolean {
return this.loanProductDetailsForm?.get('productTypeGroup')?.value === 'advances';
}
get totalTabs(): number { get totalTabs(): number {
let tabs = 6; // details, currency, settings, terms, charges, accounting let tabs = 4; // details, currency, charges, accounting (always)
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income if (this.isAdvanceProduct) {
// security & lien, limit & tenure, interest, penal & npa, renewal & closure
tabs += 5;
if (this.isOverdraftFacility) tabs += 1; // cheque book
} else {
tabs += 2; // settings, terms
if (this.isAdvancedPaymentStrategy) tabs += 3; // interest refund, payment allocation, deferred income
}
if (this.loanProductFormValid) tabs += 1; // preview if (this.loanProductFormValid) tabs += 1; // preview
return tabs; return tabs;
} }
get loanProductFormValid() { get loanProductFormValid() {
if (this.isAdvancedPaymentStrategy) { const base =
this.loanProductDetailsForm.valid && this.loanProductCurrencyForm.valid && this.loanProductAccountingForm.valid;
// Advances A/C: the curated Figma steps gate the form; Settings/Terms tabs are hidden.
if (this.isAdvanceProduct) {
return ( return (
this.loanProductDetailsForm.valid && base &&
this.loanProductCurrencyForm.valid && !!this.loanProductSecurityLienStep?.loanProductSecurityLienForm?.valid &&
this.loanProductTermsForm.valid && !!this.loanProductAdvanceLimitTenureStep?.loanProductAdvanceLimitTenureForm?.valid &&
this.loanProductSettingsForm.valid && !!this.loanProductAdvanceInterestStep?.loanProductAdvanceInterestForm?.valid &&
this.loanIncomeCapitalizationForm.valid && !!this.loanProductPenalNpaStep?.loanProductPenalNpaForm?.valid &&
this.loanProductAccountingForm.valid !!this.loanProductRenewalClosureStep?.loanProductRenewalClosureForm?.valid &&
); (!this.isOverdraftFacility || !!this.loanProductChequeBookStep?.loanProductChequeBookForm?.valid)
} else {
return (
this.loanProductDetailsForm.valid &&
this.loanProductCurrencyForm.valid &&
this.loanProductTermsForm.valid &&
this.loanProductSettingsForm.valid &&
this.loanProductAccountingForm.valid
); );
} }
// Loans A/C: original Settings + Terms path.
const loansValid = !!this.loanProductTermsForm?.valid && !!this.loanProductSettingsForm?.valid;
if (this.isAdvancedPaymentStrategy) {
return base && loansValid && !!this.loanIncomeCapitalizationForm?.valid;
}
return base && loansValid;
} }
get loanProduct() { get loanProduct() {
// Advances A/C builds its own payload: the curated steps map onto Fineract's mandatory core fields
// (principal, tenure, rate, amortization defaults) plus the whitelisted satellite params.
if (this.isAdvanceProduct) {
return this.assembleAdvanceProduct();
}
// Loans A/C: original composition (Settings/Terms present).
const loanProduct = { const loanProduct = {
...this.loanProductDetailsStep.loanProductDetails, ...this.loanProductDetailsStep.loanProductDetails,
...this.loanProductCurrencyStep.loanProductCurrency, ...this.loanProductCurrencyStep.loanProductCurrency,
...this.loanProductTermsStep.loanProductTerms, ...(this.loanProductTermsStep?.loanProductTerms ?? {}),
...this.loanProductSettingsStep.loanProductSettings, ...(this.loanProductSettingsStep?.loanProductSettings ?? {}),
...this.loanProductChargesStep.loanProductCharges, ...this.loanProductChargesStep.loanProductCharges,
...this.loanProductAccountingStep.loanProductAccounting ...this.loanProductAccountingStep.loanProductAccounting
}; };
@@ -304,6 +366,163 @@ export class CreateLoanProductComponent implements OnInit {
return loanProduct; return loanProduct;
} }
/**
* Builds the create payload for an Advances A/C product: the curated wizard steps mapped onto Fineract's
* mandatory core fields (so the standard product saves) plus the whitelisted satellite params (persisted by
* LoanProductAdvanceExtService). Only keys the backend accepts are emitted — an unknown key would be
* rejected by checkForUnsupportedParameters.
*/
private assembleAdvanceProduct(): any {
const num = (v: any): number | undefined => (v === '' || v === null || v === undefined ? undefined : Number(v));
const d = this.loanProductDetailsStep.loanProductDetails;
const cur = this.loanProductCurrencyStep.loanProductCurrency;
const sec = this.loanProductSecurityLienStep.loanProductSecurityLien;
const lt = this.loanProductAdvanceLimitTenureStep.loanProductAdvanceLimitTenure;
const intr = this.loanProductAdvanceInterestStep.loanProductAdvanceInterest;
const pn = this.loanProductPenalNpaStep.loanProductPenalNpa;
const rc = this.loanProductRenewalClosureStep.loanProductRenewalClosure;
const cb = this.isOverdraftFacility ? this.loanProductChequeBookStep?.loanProductChequeBook : null;
const defaultStrategy = this.loanProductsTemplate?.transactionProcessingStrategyOptions?.[0]?.code;
const minTenure = num(lt.minTenure);
const minRate = num(intr.minRate);
const payload: any = {
// identity + currency
name: d.name,
shortName: d.shortName,
description: d.description,
startDate: d.startDate,
closeDate: d.closeDate,
currencyCode: cur.currencyCode,
digitsAfterDecimal: cur.digitsAfterDecimal,
inMultiplesOf: 1,
// limit -> principal
minPrincipal: num(lt.minLimit),
principal: num(lt.defaultLimit) ?? num(lt.minLimit),
maxPrincipal: num(lt.maxLimit),
// tenure -> repayments (monthly)
minNumberOfRepayments: minTenure,
numberOfRepayments: num(lt.defaultTenure) ?? minTenure,
maxNumberOfRepayments: num(lt.maxTenure),
repaymentEvery: 1,
repaymentFrequencyType: 2, // months
// rate
minInterestRatePerPeriod: minRate,
interestRatePerPeriod: minRate,
maxInterestRatePerPeriod: num(intr.maxRate),
interestRateFrequencyType: 3, // per year
// Fineract mandatory engine defaults for an Advance (no EMI schedule shown to the officer)
amortizationType: 1, // equal installments
interestType: 0, // declining balance
interestCalculationPeriodType: 1, // same as repayment period
transactionProcessingStrategyCode: defaultStrategy,
daysInYearType: 1, // actual
daysInMonthType: 1, // actual
isInterestRecalculationEnabled: false,
// NPA / delinquency map onto core product fields
overdueDaysForNPA: num(pn.npaDays),
graceOnArrearsAgeing: num(pn.graceOnArrearsAgeing),
inArrearsTolerance: num(pn.inArrearsTolerance),
delinquencyBucketId: pn.delinquencyBucketId || undefined,
// accounting rule + GL mappings + charges (all standard, whitelisted)
...this.loanProductAccountingStep.loanProductAccounting,
...this.loanProductChargesStep.loanProductCharges,
// ---- satellite params (must match LoanProductAdvanceExtService.ADVANCE_PARAMETERS) ----
productTypeGroup: d.productTypeGroup,
productTypeSubGroup: d.productTypeSubGroup,
glAccount: d.glAccount || undefined,
loanProductCategory: d.loanProductCategory,
prioritySector: d.prioritySector,
cibilAccountType: d.cibilAccountType,
kfsEnabled: d.kfsEnabled,
aprEnabled: d.aprEnabled,
facilityType: lt.facilityType,
securityType: sec.securityType,
eligibleAmountBasis: sec.eligibleAmountBasis,
margin: num(sec.margin),
ltv: sec.ltv,
dpMethod: 'security_value_x_ltv',
lienRequired: this.loanProductSecurityLienStep.isLienSubType,
lienMarkingStage: sec.lienMarkingStage,
lienCertificateRequired: sec.lienCertificateRequired,
lienCertificatePrefix: sec.lienCertificatePrefix,
printCertificateOnDisbursement: sec.printOnDisbursement,
interestBasis: intr.interestBasis,
spread: num(intr.spread),
interestChargedOn: intr.interestChargedOn,
interestPostingFrequency: intr.postingFrequency,
penalApplicable: pn.penalApplicable,
penalRate: num(pn.penalRate),
penalAppliedOn: pn.penalAppliedOn,
penalGraceDays: num(pn.penalGraceDays),
npaTrigger: pn.npaTrigger,
interestAfterNpa: pn.interestAfterNpa,
renewalAllowed: rc.renewalAllowed,
renewalPeriodMonths: num(rc.renewalPeriodMonths),
renewalGraceDays: num(rc.renewalGraceDays),
preclosureAllowed: rc.preclosureAllowed,
lockInMonths: num(rc.lockInMonths),
interestOnPreclosure: rc.interestOnPreclosure,
chequeBookEnabled: this.isOverdraftFacility,
chequeType: cb?.chequeType,
defaultChequeBookSize: num(cb?.defaultBookSize),
ppsThreshold: num(cb?.ppsThreshold),
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,
// limit & tenure extras
repaymentType: lt.repaymentType,
appropriationOrder: lt.appropriationOrder,
debitBalanceAllow: lt.debitBalanceAllow,
allowDebitAfterDueDate: lt.allowDebitTransAfterDueDate,
branchLevelAccountType: lt.branchLevelAccountType,
// interest extras
interestCalcMethod: intr.calcMethod,
interestOnCreditBalance: intr.interestOnCreditBalance,
interestPostingDay: intr.postingDay,
// NPA upgrade rules
npaUpgradeOnlyAllArrears: pn.upgradeOnlyAllArrears,
npaKeepStandardIfDepositCovers: pn.keepStandardIfDepositCovers,
// cheque book full config
chequeBookSizes: (cb?.bookSizes ?? []).join('|'),
chequeMaxBooksOutstanding: num(cb?.maxBooksOutstanding),
chequeReissueWhenLeaves: num(cb?.reissueWhenLeaves),
chequeFirstBookFree: cb?.firstBookFree,
chequeFreeBooksPerYear: num(cb?.freeBooksPerYear),
chequeNamePrinted: cb?.namePrinted,
chequePrintDescription: cb?.chequePrintDescription,
chequeStopPaymentAllowed: cb?.stopPaymentAllowed,
chequeMaintainStockRegister: cb?.maintainStockRegister,
chequeAllowWithdrawalSlip: cb?.allowWithdrawalSlip,
chequeBlockNpa: cb?.blockNpa,
chequeBlockLimitExpired: cb?.blockLimitExpired,
chequeBlockOverDp: cb?.blockOverDp,
chequeBlockFrozen: cb?.blockFrozen,
chequeBlockLienReleased: cb?.blockLienReleased
};
// Drop undefined keys so we never send an unresolved value.
Object.keys(payload).forEach((k) => payload[k] === undefined && delete payload[k]);
return payload;
}
submit() { submit() {
const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault); const loanProduct = this.loanProducts.buildPayload(this.loanProduct, this.itemsByDefault);
if (this.isAdvancedPaymentStrategy) { if (this.isAdvancedPaymentStrategy) {
@@ -0,0 +1,174 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/*
* Shared styling for the Advances A/C wizard steps (Security & Lien, Limit & Tenure,
* Interest, Penal & NPA, Renewal & Closure, Cheque Book).
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.adv-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.adv-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.adv-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.adv-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-grid--3 {
grid-template-columns: repeat(3, 1fr);
@media (width <= 900px) {
grid-template-columns: repeat(2, 1fr);
}
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-subsection .adv-grid {
padding: 0;
}
.adv-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.adv-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.adv-type-option {
border: 1px solid #cbd5e1;
border-radius: 8px;
padding: 10px 14px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.adv-note {
display: flex;
align-items: flex-start;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
margin-top: 2px;
}
}
.adv-info {
padding: 12px 16px;
border-radius: 8px;
background: #f0f9ff;
border: 1px solid #bae6fd;
font-size: 13px;
color: #075985;
line-height: 1.5;
}
@@ -0,0 +1,120 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductAdvanceInterestForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Rate</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Basis</mat-label>
<mat-select formControlName="interestBasis">
@for (opt of interestBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (showSpread) {
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Spread over Deposit Rate (%)</mat-label>
<input matInput type="number" formControlName="spread" min="0" max="20" step="0.01" />
<mat-hint>Advance rate = deposit rate + spread</mat-hint>
</mat-form-field>
} @else {
<div class="adv-field"></div>
}
<div class="adv-field"></div>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="minRate" min="0" step="0.01" required />
<mat-error>Minimum Rate is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="maxRate" min="0" step="0.01" required />
<mat-error>Maximum Rate is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-sliders-h"></i>
<h3>Calculation</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Calculation Method</mat-label>
<mat-select formControlName="calcMethod">
@for (opt of calcMethodOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Year</mat-label>
<mat-select formControlName="daysInYear">
@for (opt of daysInYearOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Days in Month</mat-label>
<mat-select formControlName="daysInMonth">
@for (opt of daysInMonthOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest Charged On</mat-label>
<mat-select formControlName="interestChargedOn">
@for (opt of chargedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Credit Balance</mat-label>
<mat-select formControlName="interestOnCreditBalance">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Posting</h3>
</div>
<div class="adv-grid">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Frequency</mat-label>
<mat-select formControlName="postingFrequency">
@for (opt of postingFrequencyOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Posting Day</mat-label>
<mat-select formControlName="postingDay">
@for (opt of postingDayOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
</form>
@@ -0,0 +1,105 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Interest step for an Advances A/C product (Figma step 5). Interest basis + spread, rate bounds,
* daily calculation and monthly posting. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-interest-step',
templateUrl: './loan-product-advance-interest-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class LoanProductAdvanceInterestStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceInterestForm: UntypedFormGroup;
readonly interestBasisOptions = [
{ id: 'deposit_spread', label: 'Deposit Rate + Spread' },
{ id: 'fixed', label: 'Fixed rate' },
{ id: 'floating', label: 'Floating (EBLR-linked)' }
];
readonly calcMethodOptions = [
{ id: 'daily', label: 'Daily product (daily outstanding)' },
{ id: 'monthly', label: 'Monthly average balance' }
];
readonly daysInYearOptions = [
'Actual (365 / 366)',
'365',
'360'
];
readonly daysInMonthOptions = [
'Actual',
'30'
];
readonly chargedOnOptions = [
{ id: 'utilised', label: 'Utilised (debit) balance' },
{ id: 'limit', label: 'Sanctioned limit' }
];
readonly postingFrequencyOptions = [
'Monthly',
'Quarterly',
'Half-yearly',
'At Maturity'
];
readonly postingDayOptions = [
'Last day of month',
'1st of month',
'Anniversary day'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceInterestForm = this.formBuilder.group({
interestBasis: ['deposit_spread'],
spread: [
2.0,
[
Validators.min(0),
Validators.max(20)
]
],
minRate: [
'',
Validators.required
],
maxRate: [
'',
Validators.required
],
calcMethod: ['daily'],
daysInYear: ['Actual (365 / 366)'],
daysInMonth: ['Actual'],
interestChargedOn: ['utilised'],
interestOnCreditBalance: ['NO'],
postingFrequency: ['Monthly'],
postingDay: ['Last day of month']
});
}
get showSpread(): boolean {
return this.loanProductAdvanceInterestForm.get('interestBasis').value === 'deposit_spread';
}
get loanProductAdvanceInterest() {
return this.loanProductAdvanceInterestForm.getRawValue();
}
}
@@ -0,0 +1,155 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductAdvanceLimitTenureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-layer-group"></i>
<h3>Facility Type</h3>
</div>
<mat-radio-group formControlName="facilityType" class="adv-type-options">
<mat-radio-button value="demand" class="adv-type-option">
<span class="adv-type-option__label">Demand Loan</span>
<span class="adv-type-option__hint">Paid out once; repaid at maturity. No cheque book.</span>
</mat-radio-button>
<mat-radio-button value="overdraft" class="adv-type-option">
<span class="adv-type-option__label">Overdraft (Running)</span>
<span class="adv-type-option__hint">Draw and repay any time within the limit. Cheque book allowed.</span>
</mat-radio-button>
</mat-radio-group>
<div style="padding: 16px 24px 20px">
<div class="adv-info">
This choice decides whether the <strong>Cheque Book</strong> step appears. CC / OD sub types are always running.
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-coins"></i>
<h3>Limit</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Limit (₹)</mat-label>
<input matInput type="number" formControlName="minLimit" min="0" required />
<mat-error>Minimum Limit is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Limit (₹)</mat-label>
<input matInput type="number" formControlName="defaultLimit" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Limit (₹)</mat-label>
<input matInput type="number" formControlName="maxLimit" min="0" required />
<mat-error>Maximum Limit is <strong>required</strong></mat-error>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-clock"></i>
<h3>Tenure &amp; Repayment</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Minimum Tenure (months)</mat-label>
<input matInput type="number" formControlName="minTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: 12–15 months only</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Minimum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Tenure (months)</mat-label>
<input matInput type="number" formControlName="defaultTenure" [min]="tenureMin" [max]="tenureMax" />
@if (isFdTenureLocked) {
<mat-hint>Between 12 and 15</mat-hint>
<mat-error>Must be between <strong>12 and 15</strong> months</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maximum Tenure (months)</mat-label>
<input matInput type="number" formControlName="maxTenure" [min]="tenureMin" [max]="tenureMax" required />
@if (isFdTenureLocked) {
<mat-hint>FD policy: max 15 months (also capped at deposit maturity)</mat-hint>
} @else {
<mat-hint>Also capped at the deposit's maturity (Lien rule)</mat-hint>
}
<mat-error>
@if (isFdTenureLocked) {
Must be between <strong>12 and 15</strong> months
} @else {
Maximum Tenure is <strong>required</strong>
}
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Repayment Type</mat-label>
<mat-select formControlName="repaymentType">
@for (opt of repaymentTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full">
<mat-label>Appropriation Order</mat-label>
<mat-select formControlName="appropriationOrder">
@for (opt of appropriationOrderOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-toggle-on"></i>
<h3>Account Behaviour</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Debit Balance Allow</mat-label>
<mat-select formControlName="debitBalanceAllow">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Debit Trans After Due Date</mat-label>
<mat-select formControlName="allowDebitTransAfterDueDate">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Branch Level A/c Type</mat-label>
<mat-select formControlName="branchLevelAccountType">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>EMI fields (Number of Repayments, Repaid Every, Variable Installments) are hidden for an Advance.</span>
</div>
</form>
@@ -0,0 +1,171 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject, Input } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit & Tenure step for an Advances A/C product (Figma step 4).
*
* Facility Type here decides the rest of the flow: an Overdraft (running) facility gets a cheque book,
* a Demand Loan does not. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-advance-limit-tenure-step',
templateUrl: './loan-product-advance-limit-tenure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatRadioGroup,
MatRadioButton
]
})
export class LoanProductAdvanceLimitTenureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductAdvanceLimitTenureForm: UntypedFormGroup;
/**
* Product sub type picked on the Details step. "Loan against FD" is bank-policy capped to a
* 12–15 month tenure, so the officer cannot create an FD product outside that band. Other
* advance sub types (Gold, RD, CC, …) stay open.
*/
private _productSubType = '';
@Input()
set productSubType(value: string) {
const next = value || '';
if (next === this._productSubType) {
return;
}
this._productSubType = next;
this.applyTenurePolicy();
}
get productSubType(): string {
return this._productSubType;
}
readonly repaymentTypeOptions = [
{ id: 'bullet', label: 'Bullet – principal + interest at maturity' },
{ id: 'running', label: 'Running – any time; clear by limit expiry' }
];
readonly appropriationOrderOptions = [
{ id: 'charges_penal_interest_principal', label: 'Charges → Penal → Interest → Principal' },
{ id: 'penal_interest_principal_charges', label: 'Penal → Interest → Principal → Charges' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductAdvanceLimitTenureForm = this.formBuilder.group({
facilityType: ['demand'],
minLimit: [
'',
Validators.required
],
defaultLimit: [''],
maxLimit: [
'',
Validators.required
],
minTenure: [
12,
[
Validators.required,
Validators.min(1)
]
],
defaultTenure: [12],
maxTenure: [
15,
[
Validators.required,
Validators.min(1)
]
],
repaymentType: ['bullet'],
appropriationOrder: ['charges_penal_interest_principal'],
debitBalanceAllow: ['YES'],
allowDebitTransAfterDueDate: ['NO'],
branchLevelAccountType: ['NO']
});
}
/** Read by the wizard to decide whether the Cheque Book step applies. */
get isOverdraft(): boolean {
return this.loanProductAdvanceLimitTenureForm.get('facilityType').value === 'overdraft';
}
get loanProductAdvanceLimitTenure() {
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';
}
get tenureMin(): number {
return this.isFdTenureLocked ? 12 : 1;
}
get tenureMax(): number | null {
return this.isFdTenureLocked ? 15 : null;
}
/**
* FD advances are bank-policy capped to 12–15 months; 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.
*/
private applyTenurePolicy(): void {
const form = this.loanProductAdvanceLimitTenureForm;
if (!form) {
return;
}
const min = form.get('minTenure');
const def = form.get('defaultTenure');
const max = form.get('maxTenure');
if (this._productSubType === 'fd') {
const bounds = [
Validators.min(12),
Validators.max(15)
];
min.setValidators([
Validators.required,
...bounds
]);
def.setValidators(bounds);
max.setValidators([
Validators.required,
...bounds
]);
min.setValue(12, { emitEvent: false });
def.setValue(12, { emitEvent: false });
max.setValue(15, { emitEvent: false });
} else {
min.setValidators([
Validators.required,
Validators.min(1)
]);
def.setValidators([Validators.min(1)]);
max.setValidators([
Validators.required,
Validators.min(1)
]);
}
min.updateValueAndValidity({ emitEvent: false });
def.updateValueAndValidity({ emitEvent: false });
max.updateValueAndValidity({ emitEvent: false });
}
}
@@ -0,0 +1,144 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductChequeBookForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-money-check"></i>
<h3>Cheque Book Facility</h3>
<span class="adv-section__hint">Overdraft (running) only</span>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Type</mat-label>
<mat-select formControlName="chequeType">
@for (opt of chequeTypeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Book Sizes Allowed (leaves)</mat-label>
<mat-select formControlName="bookSizes" multiple>
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Default Book Size (leaves)</mat-label>
<mat-select formControlName="defaultBookSize">
@for (opt of bookSizeOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Max Books Outstanding</mat-label>
<input matInput type="number" formControlName="maxBooksOutstanding" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Reissue When Unused Leaves ≤</mat-label>
<input matInput type="number" formControlName="reissueWhenLeaves" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>First Cheque Book Free</mat-label>
<mat-select formControlName="firstBookFree">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Free Cheque Books per Year</mat-label>
<input matInput type="number" formControlName="freeBooksPerYear" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Name Printed on Cheque</mat-label>
<mat-select formControlName="namePrinted">
@for (opt of namePrintedOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field adv-field--full" style="grid-column: span 2">
<mat-label>Cheque Print Description</mat-label>
<input matInput formControlName="chequePrintDescription" />
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-shield-halved"></i>
<h3>Validation &amp; Security</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Positive Pay (PPS) Threshold (₹)</mat-label>
<input matInput type="number" formControlName="ppsThreshold" min="0" required />
<mat-hint>Issuer confirms cheques at or above this</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>PPS Mandatory Above (₹)</mat-label>
<input matInput type="number" formControlName="ppsMandatoryAbove" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Cheque Validity</mat-label>
<input matInput value="3 months (RBI)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Stop Payment Allowed</mat-label>
<mat-select formControlName="stopPaymentAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Maintain Cheque Stock Register</mat-label>
<mat-select formControlName="maintainStockRegister">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Allow Withdrawal Slip</mat-label>
<mat-select formControlName="allowWithdrawalSlip">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-ban"></i>
<h3>Block Cheque Issue &amp; Clearing When</h3>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="blockNpa">Account is NPA or out of order</mat-checkbox>
<mat-checkbox formControlName="blockLimitExpired">Limit has expired (renewal due)</mat-checkbox>
<mat-checkbox formControlName="blockOverDp">Debit would exceed the limit / drawing power</mat-checkbox>
<mat-checkbox formControlName="blockFrozen">Account is frozen or inoperative</mat-checkbox>
<mat-checkbox formControlName="blockLienReleased">
Pledged deposit has matured or its lien was released
</mat-checkbox>
</div>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Cheque book fees (issue, per leaf, return, stop payment) are set on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,92 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Cheque Book step (Figma step 8), shown only when the Facility Type is Overdraft (running).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-cheque-book-step',
templateUrl: './loan-product-cheque-book-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductChequeBookStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductChequeBookForm: UntypedFormGroup;
readonly chequeTypeOptions = [
'CTS-2010 · Payable at par (multi-city)',
'CTS-2010 · Local',
'Non-CTS'
];
readonly bookSizeOptions = [
'10',
'25',
'50',
'100'
];
readonly namePrintedOptions = [
'Account holder name',
'Firm / entity name',
'As per instruction'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.loanProductChequeBookForm = this.formBuilder.group({
chequeType: ['CTS-2010 · Payable at par (multi-city)'],
bookSizes: [
[
'10',
'25',
'50'
]
],
defaultBookSize: ['25'],
maxBooksOutstanding: [1],
reissueWhenLeaves: [5],
firstBookFree: ['YES'],
freeBooksPerYear: [1],
namePrinted: ['Account holder name'],
chequePrintDescription: [''],
ppsThreshold: [
50000,
[
Validators.required,
Validators.min(0)
]
],
ppsMandatoryAbove: [500000],
stopPaymentAllowed: ['YES'],
maintainStockRegister: ['YES'],
allowWithdrawalSlip: ['NO'],
blockNpa: [true],
blockLimitExpired: [true],
blockOverDp: [true],
blockFrozen: [true],
blockLienReleased: [true]
});
}
get loanProductChequeBook() {
return this.loanProductChequeBookForm.getRawValue();
}
}
@@ -7,178 +7,190 @@
--> -->
<form [formGroup]="loanProductDetailsForm"> <form [formGroup]="loanProductDetailsForm">
<div class="lpd-section"> <!-- Product Type — picked first; it decides which sections the rest of the wizard shows -->
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
maxlength="4"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-calendar-alt"></i>
<h3>{{ 'labels.inputs.Availability' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<!-- Interest Information -->
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-percentage"></i>
<h3>Interest Information</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
@for (opt of yesNoOptions; track opt.value) {
<mat-option [value]="opt.value">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductDetailsForm.get('finalIntRounding')?.value) {
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Product Classification + GL Account — combined section -->
<div class="lpd-section"> <div class="lpd-section">
<div class="lpd-section__header"> <div class="lpd-section__header">
<i class="fas fa-sitemap"></i> <i class="fas fa-sitemap"></i>
<h3>Product Classification</h3> <h3>Product Type</h3>
</div> </div>
<div class="lpd-grid"> <mat-radio-group formControlName="productTypeGroup" class="lpd-type-options">
<mat-form-field appearance="outline" class="lpd-field"> @for (group of productTypeGroups; track group.id) {
<mat-label>Product Type Group</mat-label> <mat-radio-button [value]="group.id" class="lpd-type-option">
<mat-select formControlName="productTypeGroup"> <span class="lpd-type-option__label">{{ group.label }}</span>
@for (group of productTypeGroups; track group.id) { <span class="lpd-type-option__hint">{{ group.hint }}</span>
<mat-option [value]="group.id">{{ group.label }}</mat-option> </mat-radio-button>
} }
</mat-select> </mat-radio-group>
</mat-form-field>
@if (loanProductDetailsForm.get('productTypeGroup')?.value) { @if (loanProductDetailsForm.get('productTypeGroup')?.value) {
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field"> <mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account ({{ glClassificationLabel }})</mat-label> <mat-label>Product Sub Type</mat-label>
<mat-select formControlName="glAccount"> <mat-select formControlName="productTypeSubGroup" [required]="!isExistingProduct">
@for (acc of availableGlAccounts; track acc.id) { @for (subGroup of availableSubGroups; track subGroup.id) {
<mat-option [value]="acc.id"> ({{ acc.glCode }}) {{ acc.name }} </mat-option> <mat-option [value]="subGroup.id">{{ subGroup.label }}</mat-option>
} }
</mat-select> </mat-select>
<mat-error>GL Account is required</mat-error> <mat-error>Product Sub Type is <strong>required</strong></mat-error>
</mat-form-field> </mat-form-field>
}
</div> <mat-form-field appearance="outline" class="lpd-field">
<mat-label>GL Account (Asset)</mat-label>
<mat-select
formControlName="glAccount"
[required]="!isExistingProduct"
matTooltip="Ledger GL of this product. Account numbers of its loan accounts are generated from this GL."
>
@for (acc of assetAccountOptions; track acc.id) {
<mat-option [value]="acc.id">({{ acc.glCode }}) {{ acc.name }}</mat-option>
}
</mat-select>
<mat-error>GL Account is <strong>required</strong></mat-error>
</mat-form-field>
</div>
}
</div> </div>
@if (!isProductTypeChosen) {
<div class="lpd-type-pending">
<i class="fas fa-circle-info"></i>
Select a Product Type and Sub Type to continue.
</div>
}
@if (isProductTypeChosen) {
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-info-circle"></i>
<h3>{{ 'labels.inputs.Product Name' | translate }} &amp; {{ 'labels.inputs.Description' | translate }}</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Product Name' | translate }}</mat-label>
<input
matInput
formControlName="name"
matTooltip="{{ 'tooltips.Product name is a unique identifier' | translate }}"
required
/>
<mat-error>
{{ 'labels.inputs.Product Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>{{ 'labels.inputs.Short Name' | translate }}</mat-label>
<input
matInput
formControlName="shortName"
[maxlength]="isExistingProduct ? 4 : null"
matTooltip="{{ 'tooltips.Short name is a unique identifier' | translate }}"
required
(input)="normalizeShortName($event)"
/>
@if (loanProductDetailsForm.get('shortName').hasError('pattern')) {
<mat-error>Short Name must be exactly <strong>2 capital letters</strong> (A–Z)</mat-error>
} @else {
<mat-error>
{{ 'labels.inputs.Short Name' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--row-start" (click)="startDatePicker.open()">
<mat-label>{{ 'labels.inputs.Start Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="startDatePicker"
matTooltip="{{ 'tooltips.loan product will be active and available to clients' | translate }}"
formControlName="startDate"
/>
<mat-datepicker-toggle matSuffix [for]="startDatePicker"></mat-datepicker-toggle>
<mat-datepicker #startDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field" (click)="closeDatePicker.open()">
<mat-label>{{ 'labels.inputs.Close Date' | translate }}</mat-label>
<input
matInput
[min]="minDate"
[max]="maxDate"
[matDatepicker]="closeDatePicker"
matTooltip="{{ 'tooltips.loan product will become inactive and unavailable to clients' | translate }}"
formControlName="closeDate"
/>
<mat-datepicker-toggle matSuffix [for]="closeDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closeDatePicker></mat-datepicker>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field lpd-field--full">
<mat-label>{{ 'labels.inputs.Description' | translate }}</mat-label>
<textarea
matInput
matTooltip="{{ 'tooltips.Provide additional information' | translate }}"
formControlName="description"
cdkTextareaAutosize
cdkAutosizeMinRows="2"
></textarea>
</mat-form-field>
</div>
</div>
<div class="lpd-section">
<div class="lpd-section__header">
<i class="fas fa-flag"></i>
<h3>Regulatory Classification</h3>
</div>
<div class="lpd-grid">
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Loan Product Category (RBI)</mat-label>
<mat-select formControlName="loanProductCategory">
@for (opt of loanProductCategoryOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Priority Sector</mat-label>
<mat-select formControlName="prioritySector">
@for (opt of prioritySectorOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>CIBIL Account Type</mat-label>
<mat-select formControlName="cibilAccountType">
@for (opt of cibilAccountTypeOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>Key Fact Statement (KFS)</mat-label>
<mat-select formControlName="kfsEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lpd-field">
<mat-label>APR Calculation</mat-label>
<mat-select formControlName="aprEnabled">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
}
</form> </form>
@@ -61,6 +61,57 @@ $gray-900: #202124;
&--full { &--full {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
// Starts a new row, so Start / Close Date sit together instead of one of them beside Short Name.
&--row-start {
grid-column-start: 1;
}
}
// Product Type — first section
.lpd-type-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
padding: 16px 24px 0;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lpd-type-option {
border: 1px solid $border;
border-radius: 8px;
padding: 8px 12px;
&__label {
display: block;
font-weight: 600;
color: $gray-900;
}
&__hint {
display: block;
font-size: 12px;
color: #5f6368;
}
}
.lpd-type-pending {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
} }
.lpd-checkbox-row { .lpd-checkbox-row {
@@ -15,6 +15,7 @@ import { Dates } from 'app/core/utils/dates';
import { SettingsService } from 'app/settings/settings.service'; import { SettingsService } from 'app/settings/settings.service';
import { MatTooltip } from '@angular/material/tooltip'; import { MatTooltip } from '@angular/material/tooltip';
import { CdkTextareaAutosize } from '@angular/cdk/text-field'; import { CdkTextareaAutosize } from '@angular/cdk/text-field';
import { MatRadioButton, MatRadioGroup } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -24,7 +25,9 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [ imports: [
...STANDALONE_SHARED_IMPORTS, ...STANDALONE_SHARED_IMPORTS,
MatTooltip, MatTooltip,
CdkTextareaAutosize CdkTextareaAutosize,
MatRadioGroup,
MatRadioButton
] ]
}) })
export class LoanProductDetailsStepComponent implements OnInit { export class LoanProductDetailsStepComponent implements OnInit {
@@ -50,6 +53,28 @@ export class LoanProductDetailsStepComponent implements OnInit {
} }
ngOnInit() { ngOnInit() {
// Product Type is not persisted by the backend yet, so an existing product comes back without one.
// Only a new product is made to pick it first; editing must not hide or block the rest of the form.
this.isExistingProduct = !!this.loanProductsTemplate?.id;
if (!this.isExistingProduct) {
this.loanProductDetailsForm.get('productTypeGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('productTypeSubGroup').setValidators(Validators.required);
this.loanProductDetailsForm.get('glAccount').setValidators(Validators.required);
// A new product's short name is exactly two capital letters. Saved products keep the old 4-character
// rule, so an existing short name such as 'PW16' does not stop the product from being edited.
this.loanProductDetailsForm.get('shortName').setValidators([
Validators.required,
Validators.pattern(/^[A-Z]{2}$/)
]);
this.loanProductDetailsForm.get('productTypeGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('productTypeSubGroup').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('glAccount').updateValueAndValidity({ emitEvent: false });
this.loanProductDetailsForm.get('shortName').updateValueAndValidity({ emitEvent: false });
}
// A loan / advance is an asset of the bank, so its ledger GL is always an asset account.
this.assetAccountOptions = this.loanProductsTemplate?.accountingMappingOptions?.assetAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({ this.loanProductDetailsForm.patchValue({
name: this.loanProductsTemplate.name, name: this.loanProductsTemplate.name,
shortName: this.loanProductsTemplate.shortName, shortName: this.loanProductsTemplate.shortName,
@@ -57,83 +82,119 @@ export class LoanProductDetailsStepComponent implements OnInit {
startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate), startDate: this.loanProductsTemplate.startDate && new Date(this.loanProductsTemplate.startDate),
closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate) closeDate: this.loanProductsTemplate.closeDate && new Date(this.loanProductsTemplate.closeDate)
}); });
const mappingOptions = this.loanProductsTemplate?.accountingMappingOptions ?? {};
this.assetAccountOptions = mappingOptions.assetAccountOptions ?? [];
this.liabilityAccountOptions = mappingOptions.liabilityAccountOptions ?? [];
this.loanProductDetailsForm.patchValue({
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
});
} }
/** Product Classification data */ /** True when editing a saved product; see ngOnInit. */
isExistingProduct = false;
/** Product Type -- picked first, since it decides which sections the rest of the wizard shows. */
readonly productTypeGroups = [ readonly productTypeGroups = [
{ id: 'loans', label: 'Loan' }, { id: 'advances', label: 'Advances A/C', hint: 'No EMI. Limit / drawing power against security.' },
{ id: 'advanced', label: 'Advanced' } { id: 'loans', label: 'Loans A/C', hint: 'Fixed disbursement, fixed tenure, EMI repayment.' }
]; ];
readonly productTypeSubGroups: Record<string, { id: string; label: string }[]> = { /** `shortName` is the two-letter code the product's Short Name is pre-filled with when the sub type is picked. */
loans: [ readonly productTypeSubGroups: Record<string, { id: string; label: string; shortName: string }[]> = {
{ id: 'emi', label: 'EMI' }, advances: [
{ id: 'cc', label: 'CC (Cash Credit)' }, { id: 'fd', label: 'Loan against FD', shortName: 'FD' },
{ id: 'overdraft', label: 'Overdraft' }, { id: 'rd', label: 'Loan against RD', shortName: 'RD' },
{ id: 'warehouse', label: 'Warehouse' }, { id: 'dds', label: 'Loan against DDS', shortName: 'DS' },
{ id: 'gold', label: 'Gold' } { id: 'lic', label: 'Loan against LIC Policy', shortName: 'LI' },
{ id: 'gold', label: 'Loan against Gold', shortName: 'GD' },
{ id: 'warehouse', label: 'Warehouse Receipt', shortName: 'WR' },
{ id: 'cc', label: 'Cash Credit (CC)', shortName: 'CC' },
{ id: 'overdraft', label: 'Overdraft (OD)', shortName: 'OD' },
{ id: 'kcc', label: 'Kisan Credit Card (KCC)', shortName: 'KC' },
{ id: 'bills', label: 'Bills Purchased / Discounted', shortName: 'BP' }
], ],
advanced: [ loans: [
{ id: 'fd', label: 'FD (Fixed Deposit)' }, { id: 'term', label: 'Term Loan', shortName: 'TL' },
{ id: 'rd', label: 'RD (Recurring Deposit)' } { id: 'personal', label: 'Personal Loan', shortName: 'PL' },
{ id: 'housing', label: 'Housing Loan', shortName: 'HL' },
{ id: 'vehicle', label: 'Vehicle Loan', shortName: 'VL' },
{ id: 'education', label: 'Education Loan', shortName: 'EL' },
{ id: 'business', label: 'Business / MSME Loan', shortName: 'BL' },
{ id: 'gold_emi', label: 'Gold EMI Loan', shortName: 'GE' }
] ]
}; };
/** Default GL per product type group */ /** Regulatory Classification options (UI-only; RBI / bureau reporting). */
private readonly glDefaultMap: Record<string, string> = { readonly loanProductCategoryOptions = [
loans: 'asset', { id: 'advances_deposits', label: 'Advances against deposits' },
advanced: 'asset' { id: 'gold_loan', label: 'Gold loan' },
}; { id: 'agriculture', label: 'Agriculture' },
{ id: 'msme', label: 'MSME' },
{ id: 'personal', label: 'Personal / retail' },
{ id: 'housing', label: 'Housing' },
{ id: 'other', label: 'Other' }
];
readonly prioritySectorOptions = [
{ id: 'no', label: 'No' },
{ id: 'agriculture', label: 'Agriculture' },
{ id: 'msme', label: 'MSME' },
{ id: 'weaker_sections', label: 'Weaker Sections' },
{ id: 'education', label: 'Education' },
{ id: 'housing', label: 'Housing' }
];
readonly cibilAccountTypeOptions = [
{ id: 'loan_bank_deposits', label: 'Loan against Bank Deposits' },
{ id: 'gold_loan', label: 'Gold Loan' },
{ id: 'overdraft', label: 'Overdraft' },
{ id: 'business_loan', label: 'Business Loan' },
{ id: 'other', label: 'Other' }
];
readonly yesNoOptions = [
'YES',
'NO'
];
/** The rest of the Details form stays hidden on a new product until its type and sub type are picked. */
get isProductTypeChosen(): boolean {
if (this.isExistingProduct) {
return true;
}
const value = this.loanProductDetailsForm.value;
return !!value.productTypeGroup && !!value.productTypeSubGroup;
}
get availableSubGroups() { get availableSubGroups() {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return group ? (this.productTypeSubGroups[group] ?? []) : []; return group ? (this.productTypeSubGroups[group] ?? []) : [];
} }
get glOptions(): string[] { /**
return [ * Asset GLs offered for the product's ledger GL -- the GL the product's loan account numbers are
'asset', * generated from. Separate from the Accounting tab mappings, which drive the journal entries.
'liability' */
];
}
assetAccountOptions: any[] = []; assetAccountOptions: any[] = [];
liabilityAccountOptions: any[] = [];
get availableGlAccounts(): any[] {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
if (type === 'asset') return this.assetAccountOptions;
if (type === 'liability') return this.liabilityAccountOptions;
return [];
}
get glClassificationLabel(): string {
const type = this.loanProductDetailsForm.get('glClassification')?.value;
return type === 'liability' ? 'Liability' : 'Asset';
}
get selectedGroupLabel(): string { get selectedGroupLabel(): string {
const group = this.loanProductDetailsForm.get('productTypeGroup')?.value; const group = this.loanProductDetailsForm.get('productTypeGroup')?.value;
return this.productTypeGroups.find((g) => g.id === group)?.label ?? ''; return this.productTypeGroups.find((g) => g.id === group)?.label ?? '';
} }
readonly yesNoOptions = [ /**
{ value: true, label: 'YES' }, * A new product's short name is two capital letters, so what is typed or pasted is upper-cased, anything
{ value: false, label: 'NO' } * that is not a letter is dropped, and only the first two letters are kept. Trimming here rather than with
]; * maxlength means a paste like 'g1old' still yields 'GO' instead of being cut to 'g1' first.
*/
normalizeShortName(event: Event) {
if (this.isExistingProduct) {
return;
}
// Read from the input itself: this handler can run before the form control has taken the new value.
const typed = (event.target as HTMLInputElement).value ?? '';
const cleaned = typed
.toUpperCase()
.replace(/[^A-Z]/g, '')
.slice(0, 2);
if (cleaned !== typed) {
this.loanProductDetailsForm.get('shortName').setValue(cleaned);
}
}
createLoanProductDetailsForm() { createLoanProductDetailsForm() {
this.loanProductDetailsForm = this.formBuilder.group({ this.loanProductDetailsForm = this.formBuilder.group({
@@ -150,21 +211,26 @@ export class LoanProductDetailsStepComponent implements OnInit {
closeDate: [''], closeDate: [''],
productTypeGroup: [''], productTypeGroup: [''],
productTypeSubGroup: [''], productTypeSubGroup: [''],
glClassification: ['asset'],
glAccount: [''], glAccount: [''],
debitIntCalculation: [false], // Regulatory Classification (UI-only; stripped in buildPayload)
separatePenalInterest: [false], loanProductCategory: ['advances_deposits'],
creditIntCalculation: [false], prioritySector: ['no'],
subAccountTypeBalance: [false], cibilAccountType: ['loan_bank_deposits'],
finalIntRounding: [false], kfsEnabled: ['YES'],
roundingUpTo: [null] aprEnabled: ['YES']
}); });
// Auto-set GL based on product type group // A sub type belongs to one group, so changing the group clears it.
this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe((group: string) => { this.loanProductDetailsForm.get('productTypeGroup')?.valueChanges.subscribe(() => {
const defaultVal = this.glDefaultMap[group] ?? 'asset'; this.loanProductDetailsForm.get('productTypeSubGroup')?.setValue('');
this.loanProductDetailsForm.get('glClassification')?.setValue(defaultVal); });
this.loanProductDetailsForm.get('glAccount')?.setValue('');
// Picking a sub type pre-fills the Short Name with its code; the user can still change it.
this.loanProductDetailsForm.get('productTypeSubGroup')?.valueChanges.subscribe((subGroupId: string) => {
const subGroup = this.availableSubGroups.find((s) => s.id === subGroupId);
if (subGroup) {
this.loanProductDetailsForm.get('shortName').setValue(subGroup.shortName);
}
}); });
} }
@@ -0,0 +1,106 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductPenalNpaForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-percent"></i>
<h3>Penal Charges</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Charges Applicable</mat-label>
<mat-select formControlName="penalApplicable">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Rate (% p.a.)</mat-label>
<input matInput type="number" formControlName="penalRate" min="0" max="100" step="0.01" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Applied On</mat-label>
<mat-select formControlName="penalAppliedOn">
@for (opt of appliedOnOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Penal Grace Days</mat-label>
<input matInput type="number" formControlName="penalGraceDays" min="0" />
</mat-form-field>
<div class="adv-field adv-field--full" style="grid-column: span 2">
<div class="adv-info">
RBI: penal charges are kept separate from interest, never capitalised, and shown in the Key Fact Statement.
</div>
</div>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-triangle-exclamation"></i>
<h3>Asset Classification (SMA / NPA)</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Delinquency Bucket (SMA)</mat-label>
<mat-select formControlName="delinquencyBucketId">
<mat-option [value]="''">— None —</mat-option>
@for (bucket of delinquencyBucketOptions; track bucket.id) {
<mat-option [value]="bucket.id">{{ bucket.name }}</mat-option>
}
</mat-select>
<mat-hint>SMA 0–30 / 31–60 / 61–90</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA After (days overdue)</mat-label>
<input matInput type="number" formControlName="npaDays" min="0" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>NPA Trigger</mat-label>
<mat-select formControlName="npaTrigger">
@for (opt of npaTriggerOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest after NPA</mat-label>
<mat-select formControlName="interestAfterNpa">
@for (opt of interestAfterNpaOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Grace on Arrears Ageing (days)</mat-label>
<input matInput type="number" formControlName="graceOnArrearsAgeing" min="0" />
<mat-hint>Days before an amount counts as overdue</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>In-Arrears Tolerance (₹)</mat-label>
<input matInput type="number" formControlName="inArrearsTolerance" min="0" />
<mat-hint>Small balance ignored for arrears</mat-hint>
</mat-form-field>
</div>
<div class="adv-subsection">
<div class="adv-checks">
<mat-checkbox formControlName="upgradeOnlyAllArrears">
Upgrade from NPA only when all arrears are cleared
</mat-checkbox>
<mat-checkbox formControlName="keepStandardIfDepositCovers">
Keep standard while the pledged deposit fully covers the dues
</mat-checkbox>
</div>
</div>
</div>
</form>
@@ -0,0 +1,90 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Penal charges & asset-classification (SMA / NPA) step for an Advances A/C product (Figma step 6).
* UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-penal-npa-step',
templateUrl: './loan-product-penal-npa-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox
]
})
export class LoanProductPenalNpaStepComponent {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
loanProductPenalNpaForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly appliedOnOptions = [
{ id: 'overdue', label: 'Overdue amount' },
{ id: 'outstanding', label: 'Full outstanding' }
];
readonly npaTriggerOptions = [
{ id: 'maturity', label: 'Dues overdue after maturity' },
{ id: 'out_of_order', label: 'Out of order 90 days (CC/OD)' }
];
readonly interestAfterNpaOptions = [
{ id: 'undebited', label: 'Move to Undebited Interest GL' },
{ id: 'suspense', label: 'Move to Interest Suspense GL' }
];
get delinquencyBucketOptions(): any[] {
return this.loanProductsTemplate?.delinquencyBucketOptions ?? [];
}
constructor() {
this.loanProductPenalNpaForm = this.formBuilder.group({
penalApplicable: ['YES'],
penalRate: [
2.0,
[
Validators.min(0),
Validators.max(100)
]
],
penalAppliedOn: ['overdue'],
penalGraceDays: [0],
delinquencyBucketId: [''],
npaDays: [
90,
[
Validators.required,
Validators.min(0)
]
],
npaTrigger: ['maturity'],
interestAfterNpa: ['undebited'],
graceOnArrearsAgeing: [0],
inArrearsTolerance: [0],
upgradeOnlyAllArrears: [true],
keepStandardIfDepositCovers: [true]
});
}
get loanProductPenalNpa() {
return this.loanProductPenalNpaForm.getRawValue();
}
}
@@ -0,0 +1,69 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductRenewalClosureForm">
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-arrows-rotate"></i>
<h3>Limit Renewal</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Allowed</mat-label>
<mat-select formControlName="renewalAllowed">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Period (months)</mat-label>
<input matInput type="number" formControlName="renewalPeriodMonths" min="1" required />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Renewal Grace Days</mat-label>
<input matInput type="number" formControlName="renewalGraceDays" min="0" />
<mat-hint>Days allowed after the renewal date</mat-hint>
</mat-form-field>
</div>
</div>
<div class="adv-section">
<div class="adv-section__header">
<i class="fas fa-door-open"></i>
<h3>Pre-closure</h3>
</div>
<div class="adv-grid adv-grid--3">
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Pre-closure Allowed</mat-label>
<mat-select formControlName="preclosureAllowed">
@for (opt of preclosureOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Lock-in Period (months)</mat-label>
<input matInput type="number" formControlName="lockInMonths" min="0" />
</mat-form-field>
<mat-form-field appearance="outline" class="adv-field">
<mat-label>Interest on Pre-closure</mat-label>
<mat-select formControlName="interestOnPreclosure">
@for (opt of preclosureInterestOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
</div>
<div class="adv-note">
<i class="fas fa-circle-info"></i>
<span>Renewal and pre-closure <strong>fees</strong> are set once, on the Charges step.</span>
</div>
</form>
@@ -0,0 +1,64 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Limit renewal & pre-closure rules for an Advances A/C product (Figma step 7). Fees live on the
* Charges step. UI-only for now — not sent in the create payload.
*/
@Component({
selector: 'mifosx-loan-product-renewal-closure-step',
templateUrl: './loan-product-renewal-closure-step.component.html',
styleUrls: ['../advance-steps.scss'],
imports: [...STANDALONE_SHARED_IMPORTS]
})
export class LoanProductRenewalClosureStepComponent {
private formBuilder = inject(UntypedFormBuilder);
loanProductRenewalClosureForm: UntypedFormGroup;
readonly yesNoOptions = [
'YES',
'NO'
];
readonly preclosureOptions = [
{ id: 'anytime', label: 'Anytime' },
{ id: 'after_lockin', label: 'After lock-in period' },
{ id: 'not_allowed', label: 'Not allowed' }
];
readonly preclosureInterestOptions = [
{ id: 'till_closing', label: 'Till closing date (broken period)' },
{ id: 'full_period', label: 'Full current period' }
];
constructor() {
this.loanProductRenewalClosureForm = this.formBuilder.group({
renewalAllowed: ['YES'],
renewalPeriodMonths: [
12,
[
Validators.required,
Validators.min(1)
]
],
renewalGraceDays: [30],
preclosureAllowed: ['anytime'],
lockInMonths: [0],
interestOnPreclosure: ['till_closing']
});
}
get loanProductRenewalClosure() {
return this.loanProductRenewalClosureForm.getRawValue();
}
}
@@ -0,0 +1,160 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<form [formGroup]="loanProductSecurityLienForm">
@if (!isAdvance) {
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Security &amp; Lien applies to an <strong>Advances A/C</strong> product. Choose it on the Details step.
</div>
}
@if (isAdvance) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-shield-alt"></i>
<h3>Security &amp; Margin</h3>
</div>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Security Type</mat-label>
<input matInput formControlName="securityType" matTooltip="Set from the Product Sub Type" />
<mat-hint>From Sub Type — not chosen again</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Eligible Amount Basis</mat-label>
<mat-select formControlName="eligibleAmountBasis" required>
@for (opt of eligibleAmountBasisOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
<mat-error>Eligible Amount Basis is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Margin (%)</mat-label>
<input matInput type="number" formControlName="margin" min="0" max="100" step="0.01" required />
<mat-hint>0 – 100. Drawing Power = security value × LTV</mat-hint>
@if (loanProductSecurityLienForm.get('margin').hasError('required')) {
<mat-error>Margin is <strong>required</strong></mat-error>
} @else {
<mat-error>Margin must be between 0 and 100</mat-error>
}
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>LTV (%)</mat-label>
<input matInput [value]="ltv" disabled />
<mat-hint>= 100 − Margin</mat-hint>
</mat-form-field>
</div>
</div>
@if (isLienSubType) {
<div class="lsl-section">
<div class="lsl-section__header">
<i class="fas fa-lock"></i>
<h3>Lien Settings</h3>
<span class="lsl-section__hint">FD · RD · DDS · LIC</span>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien marking</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Required</mat-label>
<input matInput value="YES (mandatory)" disabled />
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Marking Stage</mat-label>
<mat-select formControlName="lienMarkingStage" required>
@for (opt of lienMarkingStageOptions; track opt.id) {
<mat-option [value]="opt.id">{{ opt.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field lsl-field--full">
<mat-label>Eligible Deposit Products</mat-label>
<mat-select formControlName="eligibleDepositProducts" multiple required>
@for (product of depositProductOptions; track product) {
<mat-option [value]="product">{{ product }}</mat-option>
}
</mat-select>
<mat-hint>Only accounts of these deposit products can be pledged for this advance</mat-hint>
<mat-error>Pick at least one deposit product</mat-error>
</mat-form-field>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Rules</p>
<div class="lsl-checks">
<mat-checkbox formControlName="tenureNotExceedMaturity">
Tenure cannot exceed deposit maturity date
</mat-checkbox>
<mat-checkbox formControlName="autoReleaseOnClosure">
Auto-release lien when the advance is closed
</mat-checkbox>
<mat-checkbox formControlName="allowMultipleDeposits">
Allow multiple deposits for one advance
</mat-checkbox>
<mat-checkbox formControlName="adjustFromMaturity">
Adjust advance from deposit proceeds at maturity
</mat-checkbox>
<mat-checkbox formControlName="allowThirdPartyDeposit">
Allow third-party deposit (consent letter required)
</mat-checkbox>
<mat-checkbox formControlName="allowRenewalUnderLien">
Allow deposit renewal under lien (lien carries forward)
</mat-checkbox>
</div>
</div>
<div class="lsl-subsection">
<p class="lsl-group">Lien certificate</p>
<div class="lsl-grid">
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Lien Certificate Required</mat-label>
<mat-select formControlName="lienCertificateRequired">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
@if (loanProductSecurityLienForm.get('lienCertificateRequired').value === 'YES') {
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Certificate No. Prefix</mat-label>
<input matInput formControlName="lienCertificatePrefix" required />
<mat-error>Prefix is <strong>required</strong></mat-error>
</mat-form-field>
<mat-form-field appearance="outline" class="lsl-field">
<mat-label>Print on Disbursement</mat-label>
<mat-select formControlName="printOnDisbursement">
@for (opt of yesNoOptions; track opt) {
<mat-option [value]="opt">{{ opt }}</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<div class="lsl-note">
<i class="fas fa-circle-info"></i>
Interest basis and spread are set on the <strong>Interest</strong> step; the lien-certificate fee on the
<strong>Charges</strong> step.
</div>
}
}
</form>
@@ -0,0 +1,117 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
$accent: #0ea5e9;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-900: #202124;
.lsl-section {
border: 1px solid $border;
border-radius: 10px;
overflow: hidden;
background: #fff;
& + & {
margin-top: 16px;
}
&__header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 24px;
background: $gray-50;
border-bottom: 1px solid $gray-200;
i {
font-size: 14px;
color: $accent;
}
h3 {
margin: 0;
font-size: 14px;
font-weight: 600;
color: $gray-900;
}
}
&__hint {
margin-left: auto;
font-size: 12px;
color: #5f6368;
}
}
.lsl-subsection {
padding: 16px 24px;
& + & {
border-top: 1px solid $border;
}
}
.lsl-group {
margin: 0 0 12px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #5f6368;
}
.lsl-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px 16px;
padding: 16px 24px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-subsection .lsl-grid {
padding: 0;
}
.lsl-field {
width: 100%;
&--full {
grid-column: 1 / -1;
}
}
.lsl-checks {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px 32px;
@media (width <= 600px) {
grid-template-columns: 1fr;
}
}
.lsl-note {
display: flex;
align-items: center;
gap: 8px;
margin-top: 16px;
padding: 12px 24px;
border: 1px dashed $border;
border-radius: 10px;
font-size: 13px;
color: #5f6368;
i {
color: $accent;
}
}
@@ -0,0 +1,196 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, Input, inject } from '@angular/core';
import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, Validators, ReactiveFormsModule } from '@angular/forms';
import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Security & Lien step, shown only for an "Advances A/C" product.
*
* For a deposit-backed advance (Loan against FD / RD / DDS / LIC) the bank marks a lien on the
* customer's own deposit. This step captures the product-level rules for that: the margin (from which
* the LTV and drawing power follow), which deposit products may be pledged, and the lien certificate.
*
* UI-only for now: its values are NOT sent in the create payload, because the backend has no column to
* store them yet. Once the satellite table lands, wire `loanProductSecurityLien` into the payload.
*/
@Component({
selector: 'mifosx-loan-product-security-lien-step',
templateUrl: './loan-product-security-lien-step.component.html',
styleUrls: ['./loan-product-security-lien-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatTooltip,
MatCheckbox
]
})
export class LoanProductSecurityLienStepComponent implements OnInit {
private formBuilder = inject(UntypedFormBuilder);
@Input() loanProductsTemplate: any;
/** The Details step's Product Type controls, so this step reacts to the chosen group / sub type. */
@Input() productTypeGroup: AbstractControl;
@Input() productTypeSubGroup: AbstractControl;
loanProductSecurityLienForm: UntypedFormGroup;
/** Sub type -> the security it is backed by. Only deposit / policy sub types get a lien section. */
private readonly securityTypeBySubGroup: Record<string, string> = {
fd: 'Fixed Deposit',
rd: 'Recurring Deposit',
dds: 'Daily Deposit Scheme',
lic: 'LIC Policy',
gold: 'Gold Ornaments',
warehouse: 'Warehouse Receipt',
cc: 'Stock / Book Debts',
overdraft: 'Deposit / Property'
};
/** Sub types whose security is lien-marked on a deposit (rather than pledged / hypothecated). */
private readonly lienSubGroups = [
'fd',
'rd',
'dds',
'lic'
];
/** Default margin % per sub type (bank policy in real use); LTV is derived from it. */
private readonly defaultMarginBySubGroup: Record<string, number> = {
fd: 10,
rd: 10,
dds: 10,
lic: 40
};
readonly eligibleAmountBasisOptions = [
{ id: 'balance_accrued', label: 'Deposit balance + accrued interest' },
{ id: 'balance', label: 'Deposit balance only' },
{ id: 'surrender', label: 'Surrender value (LIC)' }
];
readonly lienMarkingStageOptions = [
{ id: 'disbursement', label: 'At Disbursement' },
{ id: 'sanction', label: 'At Sanction' }
];
readonly depositProductOptions = [
'Fixed Deposit – Regular',
'Fixed Deposit – Senior Citizen',
'Recurring Deposit – Regular',
'Daily Deposit Scheme'
];
readonly yesNoOptions = [
'YES',
'NO'
];
constructor() {
this.createForm();
}
ngOnInit() {
this.applySubGroup(this.productTypeSubGroup?.value);
// Keep Security Type and the default margin in step with the sub type chosen on the Details step.
this.productTypeSubGroup?.valueChanges.subscribe((subGroup: string) => this.applySubGroup(subGroup));
}
private applySubGroup(subGroup: string): void {
this.loanProductSecurityLienForm
.get('securityType')
.setValue(this.securityTypeBySubGroup[subGroup] ?? '', { emitEvent: false });
const margin = this.defaultMarginBySubGroup[subGroup];
if (margin != null && !this.loanProductSecurityLienForm.get('margin').dirty) {
this.loanProductSecurityLienForm.get('margin').setValue(margin, { emitEvent: false });
}
}
/** This whole step is only relevant to an Advances A/C product. */
get isAdvance(): boolean {
return this.productTypeGroup?.value === 'advances';
}
/** 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);
}
/** LTV = 100 − margin, shown read-only next to the margin. */
get ltv(): number | null {
const margin = this.loanProductSecurityLienForm.get('margin').value;
if (margin === null || margin === '' || isNaN(margin)) {
return null;
}
return 100 - Number(margin);
}
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)
]
],
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']
});
}
/** Flat values for the payload. Not wired into submit yet — see the class comment. */
get loanProductSecurityLien() {
const value = this.loanProductSecurityLienForm.getRawValue();
return {
securityType: value.securityType,
eligibleAmountBasis: value.eligibleAmountBasis,
margin: value.margin,
ltv: this.ltv,
lienMarkingStage: value.lienMarkingStage,
eligibleDepositProducts: value.eligibleDepositProducts,
lienRules: {
tenureNotExceedMaturity: value.tenureNotExceedMaturity,
autoReleaseOnClosure: value.autoReleaseOnClosure,
allowMultipleDeposits: value.allowMultipleDeposits,
adjustFromMaturity: value.adjustFromMaturity,
allowThirdPartyDeposit: value.allowThirdPartyDeposit,
allowRenewalUnderLien: value.allowRenewalUnderLien
},
lienCertificateRequired: value.lienCertificateRequired,
lienCertificatePrefix: value.lienCertificatePrefix,
printOnDisbursement: value.printOnDisbursement
};
}
}
@@ -71,6 +71,16 @@
} }
} }
.lps-panel {
& + & {
margin-top: 12px;
}
.lps-section__body {
padding-top: 8px;
}
}
.lps-full-width { .lps-full-width {
grid-column: 1 / -1; grid-column: 1 / -1;
} }
@@ -22,6 +22,12 @@ import { MatTooltip } from '@angular/material/tooltip';
import { MatCheckbox } from '@angular/material/checkbox'; import { MatCheckbox } from '@angular/material/checkbox';
import { MatIconButton } from '@angular/material/button'; import { MatIconButton } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import {
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
} from '@angular/material/expansion';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@Component({ @Component({
@@ -35,7 +41,11 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatCheckbox, MatCheckbox,
MatIconButton, MatIconButton,
FaIconComponent, FaIconComponent,
ReactiveFormsModule ReactiveFormsModule,
MatAccordion,
MatExpansionPanel,
MatExpansionPanelHeader,
MatExpansionPanelTitle
] ]
}) })
export class LoanProductSettingsStepComponent implements OnInit { export class LoanProductSettingsStepComponent implements OnInit {
@@ -44,9 +54,35 @@ export class LoanProductSettingsStepComponent implements OnInit {
@Input() toEdit: boolean; @Input() toEdit: boolean;
@Input() loanProductsTemplate: any; @Input() loanProductsTemplate: any;
@Input() isLinkedToFloatingInterestRates: UntypedFormControl; @Input() productTypeGroup: UntypedFormControl;
@Output() advancePaymentStrategy = new EventEmitter<string>(); @Output() advancePaymentStrategy = new EventEmitter<string>();
/**
* Provided from the Terms step's control. It can arrive after this component's ngOnInit (the Terms tab
* resolves later now that the tabs are conditional), so subscribe from the setter when it is present
* rather than assuming it in ngOnInit — otherwise a null control blanks the whole wizard.
*/
private _isLinkedToFloatingInterestRates: UntypedFormControl;
private isLinkedSubscribed = false;
@Input()
set isLinkedToFloatingInterestRates(control: UntypedFormControl) {
this._isLinkedToFloatingInterestRates = control;
if (control && !this.isLinkedSubscribed) {
this.isLinkedSubscribed = true;
control.valueChanges.subscribe((isLinked: any) => {
if (isLinked) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true);
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true);
}
});
}
}
get isLinkedToFloatingInterestRates(): UntypedFormControl {
return this._isLinkedToFloatingInterestRates;
}
loanProductSettingsForm: UntypedFormGroup; loanProductSettingsForm: UntypedFormGroup;
amortizationTypeData: any; amortizationTypeData: any;
@@ -74,6 +110,15 @@ export class LoanProductSettingsStepComponent implements OnInit {
daysInYearCustomStrategyOptions: OptionData[] = []; daysInYearCustomStrategyOptions: OptionData[] = [];
useDaysInYearCustomStrategy = false; useDaysInYearCustomStrategy = false;
/** True when the product being created is an "Advances A/C" (no-EMI, limit-based) product. */
isAdvanceAccount = false;
/** UI-only for now; not yet wired into the submitted payload (see loan-products.ts buildPayload). */
readonly loanPeriodOptions = [
{ value: 12, label: '12 Months' },
{ value: 15, label: '15 Months' }
];
/** Values to Days for Repayments */ /** Values to Days for Repayments */
defaultConfigValues: GlobalConfiguration[] = []; defaultConfigValues: GlobalConfiguration[] = [];
@@ -84,12 +129,13 @@ export class LoanProductSettingsStepComponent implements OnInit {
ngOnInit() { ngOnInit() {
this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault']; this.defaultConfigValues = this.loanProductsTemplate['itemsByDefault'];
this.isLinkedToFloatingInterestRates.valueChanges.subscribe((isLinkedToFloatingInterestRates: any) => {
if (isLinkedToFloatingInterestRates) { if (this.productTypeGroup) {
this.loanProductSettingsForm.get('isInterestRecalculationEnabled').setValue(true); this.applyAdvanceAccountRules(this.productTypeGroup.value === 'advances');
this.loanProductSettingsForm.get('allowPartialPeriodInterestCalculation').setValue(true); this.productTypeGroup.valueChanges.subscribe((productTypeGroup: string) => {
} this.applyAdvanceAccountRules(productTypeGroup === 'advances');
}); });
}
this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions; this.amortizationTypeData = this.loanProductsTemplate.amortizationTypeOptions;
this.interestTypeData = this.loanProductsTemplate.interestTypeOptions; this.interestTypeData = this.loanProductsTemplate.interestTypeOptions;
@@ -334,6 +380,8 @@ export class LoanProductSettingsStepComponent implements OnInit {
Validators.required Validators.required
], ],
allowAccrualPostingInArrears: [false], allowAccrualPostingInArrears: [false],
// Advances A/C only; UI-only for now, stripped from the payload in loan-products.ts buildPayload().
loanPeriod: [''],
// Cheque Book Settings // Cheque Book Settings
chequeBookIssueEnabled: ['NO'], chequeBookIssueEnabled: ['NO'],
@@ -453,6 +501,28 @@ export class LoanProductSettingsStepComponent implements OnInit {
} }
} }
/**
* Advances A/C has no EMI, so the Loan Schedule section is simplified: schedule type is locked to
* Cumulative (Progressive only makes sense with Advanced Payment Allocation, which is an EMI concept),
* installment-amount fixing doesn't apply, and Days in year/month are replaced by a placeholder
* Loan Period pick. loanScheduleType/canDefineInstallmentAmount stay enabled (not `.disable()`d) so they
* remain in `loanProductSettingsForm.value` and the submitted payload is unaffected by this UI change.
*/
private applyAdvanceAccountRules(isAdvance: boolean): void {
this.isAdvanceAccount = isAdvance;
const loanPeriodControl = this.loanProductSettingsForm.get('loanPeriod');
if (isAdvance) {
this.loanProductSettingsForm.patchValue({
loanScheduleType: LoanProducts.LOAN_SCHEDULE_TYPE_CUMULATIVE,
canDefineInstallmentAmount: false
});
loanPeriodControl.setValidators(Validators.required);
} else {
loanPeriodControl.clearValidators();
}
loanPeriodControl.updateValueAndValidity();
}
setConditionalControls() { setConditionalControls() {
const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides'); const allowAttributeOverrides = this.loanProductSettingsForm.get('allowAttributeOverrides');
@@ -264,6 +264,62 @@
} }
</div> </div>
<!-- Interest Information (moved here from the Details tab, next to the interest rates it qualifies) -->
<div class="lpt-section">
<div class="lpt-section-header">
<i class="fas fa-percentage lpt-section-icon"></i>
<h3>Interest Information</h3>
</div>
<div class="lpt-grid">
<mat-form-field appearance="outline">
<mat-label>Debit Int. Calculation</mat-label>
<mat-select formControlName="debitIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Separate Penal Int.</mat-label>
<mat-select formControlName="separatePenalInterest">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Credit Int. Calculation</mat-label>
<mat-select formControlName="creditIntCalculation">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Sub A/c Type Balance</mat-label>
<mat-select formControlName="subAccountTypeBalance">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Final Int. Rounding</mat-label>
<mat-select formControlName="finalIntRounding">
<mat-option [value]="true">YES</mat-option>
<mat-option [value]="false">NO</mat-option>
</mat-select>
</mat-form-field>
@if (loanProductTermsForm.value.finalIntRounding) {
<mat-form-field appearance="outline">
<mat-label>Rounding UpTo</mat-label>
<input matInput type="number" formControlName="roundingUpTo" min="0" />
</mat-form-field>
}
</div>
</div>
<!-- Interest Rate Slabs Section --> <!-- Interest Rate Slabs Section -->
<div class="lpt-section"> <div class="lpt-section">
<div class="lpt-section-header"> <div class="lpt-section-header">
@@ -111,7 +111,13 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1, repaymentStartDateType: this.loanProductsTemplate.repaymentStartDateType.id || 1,
debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false, debitBalanceAllow: this.loanProductsTemplate.debitBalanceAllow ?? false,
allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? false, allowDebitTransAfterDueDate: this.loanProductsTemplate.allowDebitTransAfterDueDate ?? false,
branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false branchLevelAccountType: this.loanProductsTemplate.branchLevelAccountType ?? false,
debitIntCalculation: this.loanProductsTemplate.debitIntCalculation ?? false,
separatePenalInterest: this.loanProductsTemplate.separatePenalInterest ?? false,
creditIntCalculation: this.loanProductsTemplate.creditIntCalculation ?? false,
subAccountTypeBalance: this.loanProductsTemplate.subAccountTypeBalance ?? false,
finalIntRounding: this.loanProductsTemplate.finalIntRounding ?? false,
roundingUpTo: this.loanProductsTemplate.roundingUpTo ?? null
}); });
if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) { if (this.loanProductsTemplate.allowApprovedDisbursedAmountsOverApplied) {
@@ -213,7 +219,14 @@ export class LoanProductTermsStepComponent implements OnInit, OnChanges {
fixedLength: [null], fixedLength: [null],
debitBalanceAllow: [false], debitBalanceAllow: [false],
allowDebitTransAfterDueDate: [false], allowDebitTransAfterDueDate: [false],
branchLevelAccountType: [false] branchLevelAccountType: [false],
// Interest Information (moved here from the Details tab)
debitIntCalculation: [false],
separatePenalInterest: [false],
creditIntCalculation: [false],
subAccountTypeBalance: [false],
finalIntRounding: [false],
roundingUpTo: [null]
}); });
} }
@@ -45,9 +45,29 @@ export class LoanProducts {
dateFormat, dateFormat,
locale locale
}; };
// Remove unnecessary properties // Remove UI-only flags that are never valid loan-product parameters (both paths).
delete loanProduct.allowAttributeConfiguration; delete loanProduct.allowAttributeConfiguration;
delete loanProduct.advancedAccountingRules; delete loanProduct.advancedAccountingRules;
delete loanProduct.loanPeriod;
// The Advances payload is assembled explicitly in the create component and already carries only
// whitelisted params (core Fineract fields + satellite ADVANCE_PARAMETERS), so it is NOT stripped.
// For the Loans path, the Details/Security fields below are not backend params, so drop them.
if (loanProduct.productTypeGroup !== 'advances') {
delete loanProduct.productTypeGroup;
delete loanProduct.productTypeSubGroup;
delete loanProduct.glAccount;
delete loanProduct.loanProductCategory;
delete loanProduct.prioritySector;
delete loanProduct.cibilAccountType;
delete loanProduct.kfsEnabled;
delete loanProduct.aprEnabled;
delete loanProduct.securityType;
delete loanProduct.lienCertificateRequired;
delete loanProduct.securityMargin;
delete loanProduct.dpType;
delete loanProduct.dpValue;
}
// In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field // In Fineract, the POST and PUT endpoints for /v1/loanproducts have a typo in the field
// allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload. // allowPartialPeriodInterestCalculation. Until that is fixed, we need to replace the field name in the payload.