Files
cbsnexora/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts
T

574 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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<Element>;
// ── 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<string, string> = {
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<number, FdPeriodUnit> = { 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<number, number> = { 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<FdPeriodUnit, number> = { 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;
}
}