/** * 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, OnChanges, SimpleChanges, ViewChild, Output, EventEmitter, ChangeDetectorRef, inject } from '@angular/core'; import { RecurringDepositsService } from '../../recurring-deposits.service'; import { ClientsService } from 'app/clients/clients.service'; import { SettingsService } from 'app/settings/settings.service'; import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util'; import { MatTable, MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow } from '@angular/material/table'; import { trigger, state, style, transition, animate } from '@angular/animations'; import { NgIf, NgSwitch, TitleCasePipe, DecimalPipe } from '@angular/common'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { FormatNumberPipe } from '../../../../pipes/format-number.pipe'; import { YesnoPipe } from '../../../../pipes/yesno.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FormsModule } from '@angular/forms'; import { buildRdChart, tenureInMonths } from 'app/products/recurring-deposit-products/rd-product-chart'; export type RdPeriodUnit = 'Days' | 'Months' | 'Years'; export interface SlabMatchResult { slab: any; slabIndex: number; appliedRate: number; maturityAmount?: number; interestEarned?: number; tenureDays?: number; daysInYear?: number; /** From the product, as the server calculated it: Simple / Compounding, and the period names. */ interestCalculation?: string; compoundingPeriod?: string; postingPeriod?: string; /** Each interest credit the deposit will receive, if every installment is paid on time. */ postingSchedule?: { date: string; interest: number }[]; } /** * Recurring Deposits Account Interest Rate Chart Step */ @Component({ selector: 'mifosx-recurring-deposits-account-interest-rate-chart-step', templateUrl: './recurring-deposits-account-interest-rate-chart-step.component.html', styleUrls: ['./recurring-deposits-account-interest-rate-chart-step.component.scss'], animations: [ trigger('expandChartSlab', [ state('collapsed', style({ height: '0px', minHeight: '0' })), state('expanded', style({ height: '*' })), transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')) ]) ], imports: [ ...STANDALONE_SHARED_IMPORTS, FormsModule, MatTable, MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell, FaIconComponent, NgSwitch, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow, MatStepperPrevious, MatStepperNext, TitleCasePipe, DateFormatPipe, FormatNumberPipe, YesnoPipe, DecimalPipe ] }) export class RecurringDepositsAccountInterestRateChartStepComponent implements OnChanges { @Input() recurringDepositsAccountTemplate: any; @Input() recurringDepositsAccountProductTemplate: any; @Input() submittedOnDate: Date | null = null; /** Active CBS base interest rate versions — when provided, overrides the product chart slabs */ @Input() cbsBaseRates: any[] = []; /** Pre-populate tenure from existing account (edit mode) */ @Input() initialTenure: { depositPeriod: number; depositPeriodFrequencyId: number; depositAmount?: number; isSeniorCitizen?: boolean; appliedRate?: number; } | null = null; @Output() tenureSelected = new EventEmitter<{ depositPeriod: number; depositPeriodFrequencyId: number; depositAmount?: number; maturityAmount?: number; interestEarned?: number; daysInYear?: number; appliedRate?: number; isSeniorCitizen?: boolean; }>(); get activeBase(): any { // The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab. return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0]; } /** * The product's own chart - the one the server prices the RD from - with each slab's senior-citizen rate taken * from the CBS base chart it was built from. Falls back to the base chart only when the product has no chart. */ get interestRateChartData(): any[] { const cbsSlabs: any[] = this.activeBase?.slabs || []; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const productSlabs: any[] = template?.accountChart?.chartSlabs ?? template?.activeChart?.chartSlabs ?? template?.interestRateCharts?.[0]?.chartSlabs ?? template?.interestRateChart?.chartSlabs ?? template?.chartSlabs ?? []; if (this.chartCache?.cbsSlabs !== cbsSlabs || this.chartCache?.productSlabs !== productSlabs) { this.chartCache = { cbsSlabs, productSlabs, slabs: this.mergeChart(cbsSlabs, productSlabs) }; } return this.chartCache.slabs; } private chartCache: { cbsSlabs: any[]; productSlabs: any[]; slabs: any[] } | null = null; /** The product's minimum deposit term in months - the server refuses anything shorter. Null when it sets none. */ /** The product's installment limits (0 = no limit). */ get minInstallment(): number { return Number(this.recurringDepositsAccountProductTemplate?.minDepositAmount) || 0; } get maxInstallment(): number { return Number(this.recurringDepositsAccountProductTemplate?.maxDepositAmount) || 0; } get productMinTenureMonths(): number | null { const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return tenureInMonths(template?.minDepositTerm, template?.minDepositTermType?.id); } private mergeChart(cbsSlabs: any[], productSlabs: any[]): any[] { const baseSlabs = this.normalizeCbsSlabs(buildRdChart(cbsSlabs, [], null).slabs); if (!productSlabs.length) { return baseSlabs; } // Products made before RD charts moved to months still hold the base chart's own year slabs const candidates = [ ...baseSlabs, ...this.normalizeCbsSlabs(cbsSlabs) ]; return productSlabs.map((slab: any) => { if (slab.seniorCitizenRate != null) { return slab; } const base = candidates.find( (candidate: any) => candidate.fromPeriod === slab.fromPeriod && (candidate.toPeriod ?? null) === (slab.toPeriod ?? null) && this.periodLabel(candidate) === this.periodLabel(slab) ); return base?.seniorCitizenRate != null ? { ...slab, seniorCitizenRate: base.seniorCitizenRate } : slab; }); } private normalizeCbsSlabs(slabs: any[]): any[] { return (slabs || []).map((s) => ({ ...s, periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel } })); } private periodLabel(slab: any): string { return String(slab.periodType?.value ?? slab.periodTypeLabel ?? '').toLowerCase(); } chartSlabsDisplayedColumns: string[] = [ 'period', 'amountRange', 'interest', 'seniorCitizenRate', 'description', 'actions' ]; /** * Derived from the client's own date of birth -- never staff-editable, so a client who is not * actually 60+ can never be priced at the senior-citizen rate by a mis-ticked box. */ isSeniorCitizen = false; readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD; /** Client age in completed years, or null when the DOB is unknown / the client is an entity. */ clientAge: number | null = null; /** False for a company/entity client, which has no age and so can never qualify. */ clientIsIndividual = true; /** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */ clientAgeResolved = false; incentivesDisplayedColumns: string[] = [ 'entityType', 'attributeName', 'conditionType', 'attributeValue', 'incentiveType', 'amount' ]; chartSlabsIncentivesDisplayedColumns: string[] = ['incentives']; expandChartSlabIndex: number | null = null; @ViewChild('chartsTable', { static: true }) chartsTableRef: MatTable; // ── Tenure Calculator ─────────────────────────────────────────────────── tenureValue: number | null = null; tenurePeriodUnit: RdPeriodUnit = 'Months'; principalAmount: number | null = null; // Monthly installment for RD matchResult: SlabMatchResult | null = null; validationWarning: string | null = null; /** Period unit options */ readonly allowedPeriodUnits: RdPeriodUnit[] = [ 'Months', 'Years' ]; private recurringDepositsService = inject(RecurringDepositsService); private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private cdr = inject(ChangeDetectorRef); ngOnChanges(changes: SimpleChanges) { if (changes['recurringDepositsAccountTemplate']) { this.resolveSeniorCitizenFromClient(); } // Only reset/re-apply when the TEMPLATE or cbsBaseRates changes, NOT when initialTenure changes alone const templateChanged = changes['recurringDepositsAccountProductTemplate'] || changes['recurringDepositsAccountTemplate'] || changes['cbsBaseRates']; const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; if (templateChanged && template) { if (this.chartsTableRef) { this.chartsTableRef.renderRows(); } this.resetCalculator(); this.tenurePeriodUnit = this.allowedPeriodUnits[1]; // Default to Months this.applyInitialTenure(); } } private applyInitialTenure(): void { if (!this.initialTenure?.depositPeriod) { return; } const freqId = this.initialTenure.depositPeriodFrequencyId; // RD: Months=2, Years=3 this.tenurePeriodUnit = freqId === 3 ? 'Years' : 'Months'; this.tenureValue = this.initialTenure.depositPeriod; if (this.initialTenure.depositAmount) { this.principalAmount = this.initialTenure.depositAmount; } // Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's // date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a // previously mis-set flag forward on every edit. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } // Convert entered tenure to Days for slab matching private toDays(value: number, unit: RdPeriodUnit): number { if (unit === 'Days') return value; if (unit === 'Months') return value * 30; return value * 365; } // Convert slab period to days private slabPeriodToDays(period: number, periodTypeValue: string): number { const p = (periodTypeValue || '').toLowerCase(); if (p.includes('month')) return period * 30; if (p.includes('year')) return period * 365; return period; // days } /** * Whether the tenure falls in the slab, counted the way the server counts it: in whole units of the slab's own * period type, so a 30-month RD is "2 years" against a year slab and lands in "1-2 Years" rather than between * slabs. Day and week slabs keep the day comparison. */ private isTenureInSlab(slab: any, tenureMonths: number, tenureDays: number): boolean { const label = this.periodLabel(slab) || 'days'; let tenure: number; let from = slab.fromPeriod ?? 0; let to = slab.toPeriod ?? Infinity; if (label.includes('month')) { tenure = tenureMonths; } else if (label.includes('year')) { tenure = Math.floor(tenureMonths / 12); } else { tenure = tenureDays; from = this.slabPeriodToDays(from, label); to = to === Infinity ? Infinity : this.slabPeriodToDays(to, label); } return tenure >= from && tenure <= to; } calculateRate() { this.validationWarning = null; this.matchResult = null; if (!this.tenureValue || this.tenureValue <= 0) { this.validationWarning = 'Please enter a valid tenure value greater than 0.'; return; } const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit); const enteredMonths = this.tenurePeriodUnit === 'Years' ? this.tenureValue * 12 : this.tenureValue; const minimumMonths = this.productMinTenureMonths; if (minimumMonths != null && enteredMonths < minimumMonths) { this.validationWarning = `Minimum RD tenure for this product is ${minimumMonths} months.`; return; } // The installment must be within the product's limits - the same check the submit and the server make, shown // here so a wrong amount is caught before the rest of the form is filled. if (!this.principalAmount || this.principalAmount <= 0) { this.validationWarning = 'Please enter the monthly installment amount.'; return; } if (this.minInstallment > 0 && this.principalAmount < this.minInstallment) { this.validationWarning = `Minimum monthly installment for this product is ₹${this.minInstallment}.`; return; } if (this.maxInstallment > 0 && this.principalAmount > this.maxInstallment) { this.validationWarning = `Maximum monthly installment for this product is ₹${this.maxInstallment}.`; return; } const slabs: any[] = this.interestRateChartData || []; if (!slabs.length) { this.validationWarning = 'No interest rate slabs are defined for this product.'; return; } let matchedSlab: any = null; let matchedIndex = -1; for (let i = 0; i < slabs.length; i++) { const slab = slabs[i]; if (this.isTenureInSlab(slab, enteredMonths, enteredDays)) { matchedSlab = slab; matchedIndex = i; break; } } if (!matchedSlab) { this.validationWarning = 'No applicable interest rate slab found for the entered tenure.'; return; } const rate = this.isSeniorCitizen && (matchedSlab.seniorCitizenRate ?? 0) > 0 ? (matchedSlab.seniorCitizenRate ?? 0) : (matchedSlab.annualInterestRate ?? 0); const result: SlabMatchResult = { slab: matchedSlab, slabIndex: matchedIndex, appliedRate: rate, tenureDays: enteredDays }; this.matchResult = result; // Show slab match immediately, maturity updates async when API responds // Maturity Estimation via Backend API if (this.principalAmount && this.principalAmount > 0) { const compoundingType = this.recurringDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id; let compoundingMonths = 1; // Default Monthly if (compoundingType === 5) compoundingMonths = 3; else if (compoundingType === 6) compoundingMonths = 6; else if (compoundingType === 7) compoundingMonths = 12; else if (compoundingType === 1) compoundingMonths = 0; // Special case for Daily in my API logic // Calculate exact months based on unit to avoid rounding errors (e.g. 10 years = 120 months, not 122) let tenureInMonths = 0; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; } else { tenureInMonths = Math.floor(enteredDays / 30); } const calculationData: any = { depositAmount: this.principalAmount, annualInterestRate: rate, tenureInMonths: tenureInMonths, interestCompoundingPeriodInMonths: compoundingMonths }; // With the product named, the server calculates with the product's own interest settings (simple/compounding, // compounding and posting periods, days in year) - the same figure the opened account will carry. const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; const productId = template?.depositProductId ?? template?.productId; if (productId) { calculationData.productId = productId; } if (this.submittedOnDate) { const date = new Date(this.submittedOnDate); // The local calendar date - toISOString() is UTC, which in IST turns midnight of the 9th into the 8th and // starts the projection a day early (a different maturity amount from the one the account gets). const pad = (n: number) => String(n).padStart(2, '0'); calculationData.submittedOnDate = `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; calculationData.dateFormat = 'yyyy-MM-dd'; // Standard ISO for API calculationData.locale = 'en'; } this.recurringDepositsService.calculateRecurringDepositInterest(calculationData).subscribe((apiResult: any) => { result.maturityAmount = apiResult.maturityAmount; result.interestEarned = apiResult.totalInterest; result.daysInYear = apiResult.daysInYear; result.interestCalculation = apiResult.interestCalculation; result.compoundingPeriod = apiResult.compoundingPeriod; result.postingPeriod = apiResult.postingPeriod; result.postingSchedule = apiResult.postingSchedule; this.matchResult = { ...result }; this.emitTenureSelection(); }); } else { this.matchResult = result; this.emitTenureSelection(); } } /** "QUATERLY" / "BIANNUAL" / "MONTHLY" (server enum names) as a person would say them. */ interestPeriodLabel(name?: string): string { const labels: Record = { DAILY: 'Daily', MONTHLY: 'Monthly', QUATERLY: 'Quarterly', BIANNUAL: 'Half-yearly', ANNUAL: 'Yearly' }; return name ? (labels[name] ?? name) : ''; } private emitTenureSelection() { const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (frequencyId !== null && this.matchResult) { this.tenureSelected.emit({ depositPeriod: this.tenureValue || 0, depositPeriodFrequencyId: frequencyId, depositAmount: this.principalAmount || undefined, maturityAmount: this.matchResult.maturityAmount, interestEarned: this.matchResult.interestEarned, daysInYear: this.matchResult.daysInYear, appliedRate: this.matchResult.appliedRate, isSeniorCitizen: this.isSeniorCitizen }); } } private resolveFrequencyId(unit: RdPeriodUnit): number | null { const options: any[] = this.recurringDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? []; const unitLower = unit.toLowerCase(); const match = options.find((o: any) => (o.value || '').toLowerCase().includes(unitLower)); if (match) return match.id; const fallback: Record = { Days: 0, Months: 2, Years: 3 }; return fallback[unit] ?? null; } resetCalculator() { this.tenureValue = null; this.principalAmount = null; this.matchResult = null; this.validationWarning = null; this.tenurePeriodUnit = 'Months'; } onPeriodUnitChange() { this.matchResult = null; this.validationWarning = null; } /** * Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template * arrives (create and edit alike), so the flag always reflects the customer on record rather than * whatever was ticked -- or previously saved -- by hand. */ private resolveSeniorCitizenFromClient(): void { const clientId = this.recurringDepositsAccountTemplate?.clientId ?? this.recurringDepositsAccountTemplate?.id; if (!clientId) { return; } this.clientsService.getClientData(String(clientId)).subscribe((client: any) => { const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate); this.clientAge = status.age; this.clientIsIndividual = status.isIndividual; this.isSeniorCitizen = status.isSeniorCitizen; this.clientAgeResolved = true; // Re-price against the now-known status if a tenure was already entered. this.onSeniorCitizenChange(); this.cdr.detectChanges(); }); } /** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */ get seniorCitizenHint(): string { if (!this.clientAgeResolved) { return 'Determined from the client’s date of birth'; } if (!this.clientIsIndividual) { return 'Not applicable to a non-individual client'; } if (this.clientAge === null) { return 'Client has no date of birth on record'; } if (this.isSeniorCitizen) { return `Client is ${this.clientAge} years old — higher rate applied`; } return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`; } onSeniorCitizenChange() { if (this.tenureValue && this.tenureValue > 0) { this.calculateRate(); } else { this.matchResult = null; } } isMatchedSlab(index: number): boolean { return this.matchResult?.slabIndex === index; } // ── Header Getters ────────────────────────────────────────────────────── /** * The RD product's own chart - the one the deposit is actually priced on. The CBS base chart (an FD master the * product's chart was built from) is shown only when the product has no chart of its own. */ private get productChart(): any { const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart || template?.activeChart || template?.interestRateCharts?.[0] || template?.interestRateChart || null ); } get chartName(): string { if (this.productChart) { return this.productChart.name || ''; } return this.activeBase?.versionLabel || 'CBS Base Interest Rate'; } get chartFromDate(): any { if (this.productChart) { return this.productChart.fromDate; } return this.activeBase?.effectiveFromDate; } get chartEndDate(): any { if (this.productChart) { return this.productChart.endDate; } return null; } get chartDescription(): string { if (this.productChart) { return this.productChart.description || ''; } return this.activeBase?.remarks || ''; } get isActive(): boolean { if (this.productChart) { return this.productChart.isActive ?? true; } return this.activeBase?.isActive ?? true; } get isPrimaryGroupingByAmount(): boolean { const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return !!( template?.accountChart?.isPrimaryGroupingByAmount || template?.activeChart?.isPrimaryGroupingByAmount || template?.interestRateCharts?.[0]?.isPrimaryGroupingByAmount || template?.interestRateChart?.isPrimaryGroupingByAmount || template?.isPrimaryGroupingByAmount ); } }