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

626 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,
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 {
// The recurring deposit's own CBS base chart; its active version carries the senior citizen rate of each slab.
return (this.cbsBaseRates || []).find((r) => r.isActive) ?? (this.cbsBaseRates || [])[0];
}
/**
* 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
);
}
}