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
niteshandClaude Opus 5.5 3e5cc26e49 feat(fixed-deposits): FD quote from the account engine, FD Special payout schedule popup, Special terms locked
- 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>
2026-10-20 16:44:47 +05:30

635 lines
23 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 { 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;
}
}