/** * 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, Output, EventEmitter, ViewChild, ChangeDetectorRef, inject } from '@angular/core'; import { MatTableDataSource, MatTable, MatColumnDef, MatHeaderCellDef, MatHeaderCell, MatCellDef, MatCell, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow } from '@angular/material/table'; import { animate, state, style, transition, trigger } from '@angular/animations'; import { NgIf, NgSwitch, TitleCasePipe, DecimalPipe } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import { FixedDepositsService } from '../../fixed-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'; export type FdPeriodUnit = 'Days' | 'Months' | 'Years'; export interface SlabMatchResult { slab: any; slabIndex: number; appliedRate: number; estimatedMonthlyPayout?: number; maturityAmount?: number; interestEarned?: number; tenureDays?: number; } @Component({ selector: 'mifosx-fixed-deposit-account-interest-rate-chart-step', templateUrl: './fixed-deposit-account-interest-rate-chart-step.component.html', styleUrls: ['./fixed-deposit-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, NgSwitch, MatHeaderRowDef, MatHeaderRow, MatRowDef, MatRow, TitleCasePipe, DateFormatPipe, DecimalPipe ] }) export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges { private fixedDepositsService = inject(FixedDepositsService); private clientsService = inject(ClientsService); private settingsService = inject(SettingsService); private cdr = inject(ChangeDetectorRef); @Input() fixedDepositsAccountTemplate: any; @Input() fixedDepositsAccountProductTemplate: any; /** 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; /** Submission date for exact calculation (passed from create/edit form) */ @Input() submittedOnDate: Date | null = null; activeBase: any = null; @Output() tenureSelected = new EventEmitter<{ depositPeriod: number; depositPeriodFrequencyId: number; depositAmount?: number; maturityAmount?: number; interestEarned?: number; appliedRate?: number; isSeniorCitizen?: boolean; }>(); interestRateChartData: any[] = []; chartSlabsDisplayedColumns: string[] = [ 'period', 'amountRange', 'interest', 'seniorCitizenRate', 'description', 'actions' ]; /** * Derived from the client's own date of birth -- never staff-editable. Setting this by hand is what * let a 24-year-old's FD get priced at the senior-citizen rate, so the checkbox is display-only and * this value comes solely from {@link deriveSeniorCitizenStatus}. */ 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: FdPeriodUnit = 'Days'; principalAmount: number | null = null; get showPrincipalInput(): boolean { return true; // shown for all FD types } matchResult: SlabMatchResult | null = null; validationWarning: string | null = null; // ── FD Type labels ────────────────────────────────────────────────────── readonly fdTypeLabels: Record = { SINGLE: 'FD Single (Days < 1 Year)', DOUBLE: 'FD Double (≥ 1 Year)', SPECIAL: 'FD Special (Monthly Interest Payout)' }; get fdType(): string { return this.fixedDepositsAccountProductTemplate?.fdType || ''; } get fdTypeLabel(): string { return this.fdTypeLabels[this.fdType] || this.fdType; } /** Period unit options driven by fdType */ get allowedPeriodUnits(): FdPeriodUnit[] { if (this.fdType === 'SINGLE') return ['Days']; if (this.fdType === 'DOUBLE') return [ 'Months', 'Years' ]; return [ 'Days', 'Months', 'Years' ]; } get isSpecial(): boolean { return this.fdType === 'SPECIAL'; } ngOnChanges(changes: SimpleChanges) { if (changes['fixedDepositsAccountTemplate']) { this.resolveSeniorCitizenFromClient(); } // Only reset/re-apply when TEMPLATE or cbsBaseRates changes, NOT when initialTenure/submittedOnDate changes alone const templateChanged = changes['fixedDepositsAccountProductTemplate'] || changes['cbsBaseRates']; if (templateChanged && this.fixedDepositsAccountProductTemplate) { const fdType = this.fixedDepositsAccountProductTemplate?.fdType; this.activeBase = (this.cbsBaseRates || []).find((r) => r.fdType === fdType && r.isActive); this.interestRateChartData = this.activeBase?.slabs ? this.normalizeCbsSlabs(this.activeBase.slabs) : (this.fixedDepositsAccountProductTemplate.accountChart?.chartSlabs ?? []); if (this.chartsTableRef) { this.chartsTableRef.renderRows(); } this.resetCalculator(); this.tenurePeriodUnit = this.allowedPeriodUnits[0]; // Pre-fill tenure in edit mode this.applyInitialTenure(); } } private applyInitialTenure(): void { if (!this.initialTenure?.depositPeriod) { return; } // Map frequency ID to period unit: Days=0, Weeks=1, Months=2, Years=3 const freqId = this.initialTenure.depositPeriodFrequencyId; const unitMap: Record = { 0: 'Days', 1: 'Days', 2: 'Months', 3: 'Years' }; const unit: FdPeriodUnit = unitMap[freqId] ?? 'Months'; if (this.allowedPeriodUnits.includes(unit)) { this.tenurePeriodUnit = unit; } else { this.tenurePeriodUnit = this.allowedPeriodUnits[0]; } 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, which is exactly how a 24-year-old's FD kept its // senior-citizen rate. // Auto-calculate when tenure is pre-filled (edit mode) setTimeout(() => this.calculateRate(), 0); } private normalizeCbsSlabs(slabs: any[]): any[] { return (slabs || []).map((s) => ({ ...s, periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel } })); } // Convert entered tenure to Days for slab matching private toDays(value: number, unit: FdPeriodUnit): number { if (unit === 'Days') return value; if (unit === 'Months') return value * 30; return value * 365; } // Convert slab period (which is stored in its own periodType) 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; } // FD Type specific validation const enteredDays = this.toDays(this.tenureValue, this.tenurePeriodUnit); if (this.fdType === 'SINGLE' && enteredDays >= 365) { this.validationWarning = 'FD Single allows tenure less than 1 year (max 364 days). Please enter a shorter tenure.'; return; } if (this.fdType === 'DOUBLE' && enteredDays < 365) { this.validationWarning = 'FD Double requires tenure of at least 1 year (minimum 12 months). Please enter a longer tenure.'; 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]; 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. Please check the rate chart below.'; 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 if principal entered const frequencyId = this.resolveFrequencyId(this.tenurePeriodUnit); if (this.principalAmount && this.principalAmount > 0) { // Years and Months convert exactly; a Days tenure is sent as days and never converted. let tenureInMonths: number | undefined; let tenureInDays: number | undefined; if (this.tenurePeriodUnit === 'Years') { tenureInMonths = this.tenureValue * 12; } else if (this.tenurePeriodUnit === 'Months') { tenureInMonths = this.tenureValue; } else { // Sent as days, whatever the count. Converting to whole months silently dropped the // remainder — a 200-day deposit quoted as six months lost 20 days of interest, and the // screen still said "200 Days" above the figure. The backend takes days directly and // works the calendar out itself. tenureInDays = enteredDays; } // Map compounding period type id → months (0=Daily, 1=Daily, 4=Monthly, 5=Quarterly, 6=SemiAnnual, 7=Annual) const compMap: Record = { 1: 0, 4: 1, 5: 3, 6: 6, 7: 12 }; const compId = this.fixedDepositsAccountProductTemplate?.interestCompoundingPeriodType?.id ?? 4; const compMonths = compMap[compId] ?? 1; // Map posting period type id → months const postId = this.fixedDepositsAccountProductTemplate?.interestPostingPeriodType?.id ?? 4; const postMonths = compMap[postId] ?? 1; const emitResult = () => { if (frequencyId !== null) { this.tenureSelected.emit({ depositPeriod: this.tenureValue, depositPeriodFrequencyId: frequencyId, depositAmount: this.principalAmount || undefined, maturityAmount: result.maturityAmount, interestEarned: result.interestEarned, appliedRate: result.appliedRate, isSeniorCitizen: this.isSeniorCitizen }); } }; // Get submittedOnDate — Input takes priority over template data let submittedOnDateStr: string; if (this.submittedOnDate) { submittedOnDateStr = this.submittedOnDate.toISOString().split('T')[0]; } else { const submittedOnDateRaw = this.fixedDepositsAccountTemplate?.timeline?.submittedOnDate ?? this.fixedDepositsAccountProductTemplate?.timeline?.submittedOnDate; if (submittedOnDateRaw) { const d = Array.isArray(submittedOnDateRaw) ? new Date(submittedOnDateRaw[0], submittedOnDateRaw[1] - 1, submittedOnDateRaw[2]) : new Date(submittedOnDateRaw); submittedOnDateStr = !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : new Date().toISOString().split('T')[0]; } else { submittedOnDateStr = new Date().toISOString().split('T')[0]; } } const daysInYearVal = this.fixedDepositsAccountProductTemplate?.interestCalculationDaysInYearType?.id === 360 ? 360 : 365; // Always use backend API for accurate calculation (including daily compounding with 0) this.fixedDepositsService .calculateFixedDepositInterest({ principalAmount: this.principalAmount, annualInterestRate: rate, tenureInMonths, tenureInDays, interestCompoundingPeriodInMonths: compMonths, // 0 = daily, now supported by backend interestPostingPeriodInMonths: postMonths, submittedOnDate: submittedOnDateStr, daysInYear: daysInYearVal }) .subscribe({ next: (apiResult: any) => { result.maturityAmount = apiResult.maturityAmount; result.interestEarned = apiResult.totalInterest ?? Math.round((apiResult.maturityAmount - this.principalAmount) * 100) / 100; this.matchResult = { ...result }; emitResult(); }, error: () => { // Fallback if API fails: frontend formula const r = rate / 100; if (compMonths === 0) { // Daily compounding fallback result.maturityAmount = Math.round(this.principalAmount * Math.pow(1 + r / daysInYearVal, enteredDays) * 100) / 100; } else { const n = 12 / compMonths; const effectiveMonths = tenureInMonths ?? enteredDays / 30; const rPerCompounding = rate / (n * 100); // Whole compounding periods, then simple interest on the days left over. Flooring the // periods and stopping there dropped the remainder entirely: a 200-day deposit // compounding monthly quoted only six months of interest and lost the other twenty // days, which is the depositor's money. const wholePeriods = Math.floor(effectiveMonths / compMonths); const compounded = this.principalAmount * Math.pow(1 + rPerCompounding, wholePeriods); const leftoverDays = enteredDays - wholePeriods * compMonths * 30; const leftoverInterest = leftoverDays > 0 ? (compounded * rate * leftoverDays) / (100 * daysInYearVal) : 0; result.maturityAmount = Math.round((compounded + leftoverInterest) * 100) / 100; } result.interestEarned = Math.round((result.maturityAmount - this.principalAmount) * 100) / 100; this.matchResult = { ...result }; emitResult(); } }); } else { this.matchResult = result; if (frequencyId !== null) { this.tenureSelected.emit({ depositPeriod: this.tenureValue, depositPeriodFrequencyId: frequencyId, depositAmount: undefined, maturityAmount: undefined, interestEarned: undefined, appliedRate: result.appliedRate, isSeniorCitizen: this.isSeniorCitizen }); } } } private resolveFrequencyId(unit: FdPeriodUnit): number | null { const options: any[] = this.fixedDepositsAccountProductTemplate?.periodFrequencyTypeOptions ?? []; const unitLower = unit.toLowerCase(); const match = options.find((o: any) => (o.value || '').toLowerCase().includes(unitLower)); if (match) return match.id; // Fineract standard fallback: Days=0, Months=2, Years=3 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 = this.allowedPeriodUnits[0]; } 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.fixedDepositsAccountTemplate?.clientId ?? this.fixedDepositsAccountTemplate?.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; } get chartName(): string { if (this.activeBase) { return this.activeBase.versionLabel || 'CBS Base Interest Rate'; } return this.fixedDepositsAccountProductTemplate?.accountChart?.name || 'CBS Base Interest Rate'; } get chartFromDate(): any { if (this.activeBase) { return this.activeBase.effectiveFromDate; } return this.fixedDepositsAccountProductTemplate?.accountChart?.fromDate; } get chartEndDate(): any { if (this.activeBase) { return null; } return this.fixedDepositsAccountProductTemplate?.accountChart?.endDate; } get chartDescription(): string { if (this.activeBase) { return this.activeBase.remarks || ''; } return this.fixedDepositsAccountProductTemplate?.accountChart?.description || ''; } get isActive(): boolean { if (this.activeBase) { return this.activeBase.isActive; } const template = this.fixedDepositsAccountProductTemplate || this.fixedDepositsAccountTemplate; return template?.accountChart?.isActive ?? true; } get isPrimaryGroupingByAmount(): boolean { return !!this.fixedDepositsAccountProductTemplate?.accountChart?.isPrimaryGroupingByAmount; } }