622 lines
23 KiB
TypeScript
622 lines
23 KiB
TypeScript
/**
|
||
* 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 {
|
||
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
|
||
}
|
||
|
||
/**
|
||
* 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<Element>;
|
||
|
||
// ── 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<string, string> = {
|
||
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<RdPeriodUnit, 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 = '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
|
||
);
|
||
}
|
||
}
|