- Rate chart step (account opening): the rate, maturity date, maturity amount and interest now come from POST fixeddepositaccounts/account-preview for every FD type, so the quote matches the account once created (the old calculate-fd-interest call counted quarters from the deposit date and was off, e.g. FD DOUBLE by 36,979.23). The client-side formula fallback is removed: on an error the message is shown, nothing is estimated. - Dates are sent as the local calendar day (toISOString() turned 21 Oct at local midnight into 20 Oct). - FD Special: total interest paid out monthly, monthly payout, principal at maturity, maturity date, number of payouts, and "View payout schedule", which opens the existing Monthly Interest Payout Schedule dialog in a new preview mode (Days column, no paid / pending status). - Preview step: FD Special labels (total interest paid out monthly, principal at maturity). - FD product terms: choosing FD Special locks Interest Calculation Type to Simple, as Compounding and Posting are locked to Monthly (the backend refuses anything else). - Preview step: three existing `!= null` template checks rewritten for the eqeqeq lint rule, same meaning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
635 lines
23 KiB
TypeScript
635 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,
|
||
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 { MatDialog } from '@angular/material/dialog';
|
||
import { InterestScheduleDialogComponent } from '../../fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
|
||
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';
|
||
|
||
/**
|
||
* yyyy-MM-dd of the calendar day as the user sees it. toISOString() gives the UTC day instead, which in India is the day
|
||
* before for any date at local midnight - so a deposit opened on the 21st was quoted from the 20th.
|
||
*/
|
||
function localIsoDate(d: Date): string {
|
||
const pad = (n: number) => String(n).padStart(2, '0');
|
||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||
}
|
||
|
||
/** FD quote from POST fixeddepositaccounts/account-preview (worked out by the account engine, every FD type). */
|
||
export interface SpecialPayoutPreview {
|
||
fdType: string;
|
||
depositAmount: number;
|
||
annualInterestRate: number;
|
||
seniorCitizen: boolean;
|
||
startDate: string;
|
||
maturityDate: string;
|
||
numberOfPayouts: number;
|
||
fullMonthPayout: number;
|
||
totalInterest: number;
|
||
maturityAmount: number;
|
||
totalValueAtMaturity: number;
|
||
periods: { period: number; fromDate: string; toDate: string; days: number; interestAmount: number }[];
|
||
}
|
||
|
||
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);
|
||
private dialog = inject(MatDialog);
|
||
|
||
@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;
|
||
/** FD Special only: the backend's quote and payout schedule for the tenure and amount entered. */
|
||
specialPreview: SpecialPayoutPreview | null = null;
|
||
specialPreviewLoading = false;
|
||
specialPreviewError: string | 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;
|
||
this.specialPreview = null;
|
||
this.specialPreviewError = 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) {
|
||
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 = localIsoDate(this.submittedOnDate);
|
||
} 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()) ? localIsoDate(d) : localIsoDate(new Date());
|
||
} else {
|
||
submittedOnDateStr = localIsoDate(new Date());
|
||
}
|
||
}
|
||
// The whole quote - rate, maturity date, maturity amount and, for FD Special, the month-by-month payout
|
||
// schedule - comes from the backend, which builds the would-be account from the product exactly as Submit does
|
||
// (its posting periods, compounding, days in year, rate card, senior-citizen rate) and runs the account's own
|
||
// interest engine. Nothing is worked out here: on an error the quote is simply not shown, never estimated.
|
||
if (frequencyId !== null) {
|
||
this.loadAccountPreview(result, rate, submittedOnDateStr, frequencyId, 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
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
/** The backend's payout schedule in the same popup the account itself uses, in its quote (preview) mode. */
|
||
openSpecialSchedule(): void {
|
||
if (!this.specialPreview) return;
|
||
this.dialog.open(InterestScheduleDialogComponent, {
|
||
width: '760px',
|
||
data: {
|
||
preview: true,
|
||
currencyCode: this.fixedDepositsAccountProductTemplate?.currency?.code ?? 'INR',
|
||
note:
|
||
'Worked out by the account engine from the product, starting ' +
|
||
this.specialPreview.startDate +
|
||
' (the day the account is opened) and ending the day before maturity on ' +
|
||
this.specialPreview.maturityDate +
|
||
'. The first and last periods are part-months; each pays for the days it covers.',
|
||
schedule: this.specialPreview.periods.map((p) => ({
|
||
fromDate: p.fromDate,
|
||
toDate: p.toDate,
|
||
days: p.days,
|
||
interestAmount: Number(p.interestAmount),
|
||
paid: false
|
||
}))
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Asks the backend for the quote (POST fixeddepositaccounts/account-preview) with the same terms Submit sends: the
|
||
* product's own interest terms, and the senior-citizen rate the same way Submit overrides it. The rate, maturity
|
||
* amount and interest shown are the backend's, so they match the account once it is created.
|
||
*/
|
||
private loadAccountPreview(
|
||
result: SlabMatchResult,
|
||
rate: number,
|
||
submittedOnDateStr: string,
|
||
frequencyId: number,
|
||
emitResult: () => void
|
||
): void {
|
||
const pt = this.fixedDepositsAccountProductTemplate ?? {};
|
||
const body: any = {
|
||
clientId: pt.clientId ?? this.fixedDepositsAccountTemplate?.clientId,
|
||
productId: pt.depositProductId ?? pt.productId ?? this.fixedDepositsAccountTemplate?.productId,
|
||
submittedOnDate: submittedOnDateStr,
|
||
dateFormat: 'yyyy-MM-dd',
|
||
locale: 'en',
|
||
depositAmount: this.principalAmount,
|
||
depositPeriod: this.tenureValue,
|
||
depositPeriodFrequencyId: frequencyId
|
||
};
|
||
if (pt.nominalAnnualInterestRate != null) body.nominalAnnualInterestRate = pt.nominalAnnualInterestRate;
|
||
if (pt.interestCompoundingPeriodType?.id != null)
|
||
body.interestCompoundingPeriodType = pt.interestCompoundingPeriodType.id;
|
||
if (pt.interestPostingPeriodType?.id != null) body.interestPostingPeriodType = pt.interestPostingPeriodType.id;
|
||
if (pt.interestCalculationType?.id != null) body.interestCalculationType = pt.interestCalculationType.id;
|
||
if (pt.interestCalculationDaysInYearType?.id != null)
|
||
body.interestCalculationDaysInYearType = pt.interestCalculationDaysInYearType.id;
|
||
if (this.isSeniorCitizen && rate > 0) {
|
||
body.isSeniorCitizen = true;
|
||
body.seniorCitizenInterestRate = rate;
|
||
body.nominalAnnualInterestRate = rate;
|
||
}
|
||
|
||
this.specialPreview = null;
|
||
this.specialPreviewError = null;
|
||
this.specialPreviewLoading = true;
|
||
this.fixedDepositsService.accountPreview(body).subscribe({
|
||
next: (preview: SpecialPayoutPreview) => {
|
||
this.specialPreviewLoading = false;
|
||
this.specialPreview = preview;
|
||
result.appliedRate = Number(preview.annualInterestRate);
|
||
result.interestEarned = Number(preview.totalInterest);
|
||
result.estimatedMonthlyPayout = preview.fullMonthPayout != null ? Number(preview.fullMonthPayout) : undefined;
|
||
result.maturityAmount = Number(preview.maturityAmount);
|
||
this.matchResult = { ...result };
|
||
emitResult();
|
||
this.cdr.markForCheck();
|
||
},
|
||
error: (e: any) => {
|
||
this.specialPreviewLoading = false;
|
||
this.specialPreviewError =
|
||
e?.error?.errors?.[0]?.defaultUserMessage ||
|
||
e?.error?.defaultUserMessage ||
|
||
'Could not work out the maturity. Check the deposit details and try again.';
|
||
result.maturityAmount = undefined;
|
||
result.interestEarned = undefined;
|
||
this.matchResult = { ...result };
|
||
this.cdr.markForCheck();
|
||
}
|
||
});
|
||
}
|
||
|
||
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.specialPreview = null;
|
||
this.specialPreviewError = null;
|
||
this.validationWarning = null;
|
||
this.tenurePeriodUnit = this.allowedPeriodUnits[0];
|
||
}
|
||
|
||
onPeriodUnitChange() {
|
||
this.matchResult = null;
|
||
this.specialPreview = null;
|
||
this.specialPreviewError = 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;
|
||
this.specialPreview = null;
|
||
this.specialPreviewError = 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;
|
||
}
|
||
}
|