Four independent, self-contained changes bundled from this session's work:
1. Auto-derive Senior Citizen / Minor / Customer Segment instead of manual entry
Senior Citizen was a manual, staff-editable checkbox on FD/RD account
creation with no validation against the client's actual age -- this let
non-senior clients get the senior-citizen interest rate by mistake (found
twice on live test accounts, where m_savings_account.is_senior_citizen
was true despite the client being confirmed non-senior at the client
record level). Likewise "minor" client classification was hardcoded to
the age-18 threshold with no single source of truth, and FD/RD account
creation's "Customer Segment" (Individual / Non-Individual) had no
connection to the Individual/Non-Individual Legal Form already captured
at client creation.
- New src/app/clients/shared/client-age.util.ts centralises age/DOB
derivation: MINOR_AGE_THRESHOLD (now 10, was 18), SENIOR_CITIZEN_AGE_
THRESHOLD (60), AGE_OF_MAJORITY (18, kept separate from the minor
threshold -- majority date math is unrelated to the minor/adult
classification threshold), calculateAge/isMinorAge/isSeniorCitizenAge/
isIndividualClient/deriveSeniorCitizenStatus.
- FD/RD interest-rate-chart steps: Senior Citizen checkbox is now derived
from the client's DOB (age >= 60, individual clients only) and
disabled for manual editing; a hint explains why it is checked/
unchecked.
- Client general step and all four account-details-steps (savings,
current, FD, RD): replaced hardcoded 18/60 literals with the shared
helpers; "Must be below 18" hints now read the actual threshold.
- FD/RD account-details-steps: Customer Segment auto-selects to match
the client's Legal Form (exact match, not substring -- a substring
match would misclassify "Individual" as matching "Non-Individual").
- RD preview step: fixed a pre-existing display bug that always showed
the product's default Customer Segment instead of what was actually
selected on the account.
2. Fix wrong Account Name / Account Type on Disbursement Review screens
Clerk disbursement review and "My Disbursement Requests" showed the
account NUMBER instead of the holder's name, and always labelled the
account type "SAVINGS" regardless of the real account type (FD/RD/
Share/Loan), because transaction-entry.component.ts's account-type
mapping only checked for CA/CB and its account-name fallback chain
never tried clientName. Added creditEntryAccountType() covering
GL/CA/CB/FD/RD/SHR/LA, and accountTypeLabel() display helpers on both
review screens.
3. Stop reversed transactions from distorting displayed running balances
Passbook and Transactions tabs were including reversed transactions in
their client-side running-balance recalculation, so a reversed posting
(visible struck-through) still shifted the balance shown for every
transaction after it. Both now filter out txn.reversed before computing
running balances; Transactions tab also defaults "Hide Reversed" to on.
4. New Interest Schedule dialog on the FD account view
Added custom-dialogs/interest-schedule-dialog, opened from the FD
General tab, showing the account's period-by-period interest schedule.
Excludes src/environments/.env.ts (auto-generated build version/hash
stamp, regenerated on every build -- not meaningful to hand-commit).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
560 lines
20 KiB
TypeScript
560 lines
20 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,
|
||
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) {
|
||
// Convert tenure to months for backend API
|
||
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);
|
||
}
|
||
|
||
// 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,
|
||
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 totalCompoundings = Math.floor(tenureInMonths / compMonths);
|
||
const rPerCompounding = rate / (n * 100);
|
||
result.maturityAmount =
|
||
Math.round(this.principalAmount * Math.pow(1 + rPerCompounding, totalCompoundings) * 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;
|
||
}
|
||
}
|