/** * 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'; export type RdPeriodUnit = 'Days' | 'Months' | 'Years'; export interface SlabMatchResult { slab: any; slabIndex: number; appliedRate: number; maturityAmount?: number; interestEarned?: number; tenureDays?: number; daysInYear?: 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 { return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive); } get interestRateChartData(): any[] { if (this.activeBase?.slabs?.length) { return this.normalizeCbsSlabs(this.activeBase.slabs); } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; if (!template) return []; return ( template.accountChart?.chartSlabs ?? template.activeChart?.chartSlabs ?? template.interestRateCharts?.[0]?.chartSlabs ?? template.interestRateChart?.chartSlabs ?? template.chartSlabs ?? [] ); } private normalizeCbsSlabs(slabs: any[]): any[] { return (slabs || []).map((s) => ({ ...s, periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel } })); } 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 } 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 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]; const periodTypeValue = slab.periodType?.value || 'Days'; const fromDays = this.slabPeriodToDays(slab.fromPeriod ?? 0, periodTypeValue); const toDays = slab.toPeriod != null ? this.slabPeriodToDays(slab.toPeriod, periodTypeValue) : Infinity; if (enteredDays >= fromDays && enteredDays <= toDays) { 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 }; if (this.submittedOnDate) { const date = new Date(this.submittedOnDate); calculationData.submittedOnDate = date.toISOString().split('T')[0]; 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; this.matchResult = { ...result }; this.emitTenureSelection(); }); } else { this.matchResult = result; this.emitTenureSelection(); } } 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 ────────────────────────────────────────────────────── get chartName(): string { if (this.activeBase) { return this.activeBase.versionLabel || 'CBS Base Interest Rate'; } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart?.name || template?.activeChart?.name || template?.interestRateCharts?.[0]?.name || template?.interestRateChart?.name || template?.name || '' ); } get chartFromDate(): any { if (this.activeBase) { return this.activeBase.effectiveFromDate; } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart?.fromDate || template?.activeChart?.fromDate || template?.interestRateCharts?.[0]?.fromDate || template?.interestRateChart?.fromDate || template?.fromDate ); } get chartEndDate(): any { if (this.activeBase) { return null; } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart?.endDate || template?.activeChart?.endDate || template?.interestRateCharts?.[0]?.endDate || template?.interestRateChart?.endDate || template?.endDate ); } get chartDescription(): string { if (this.activeBase) { return this.activeBase.remarks || ''; } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart?.description || template?.activeChart?.description || template?.interestRateCharts?.[0]?.description || template?.interestRateChart?.description || template?.description || '' ); } get isActive(): boolean { if (this.activeBase) { return this.activeBase.isActive; } const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate; return ( template?.accountChart?.isActive ?? template?.activeChart?.isActive ?? template?.interestRateCharts?.[0]?.isActive ?? template?.interestRateChart?.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 ); } }