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
niteshandClaude Sonnet 5 395fb8ace8
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
feat(clients,deposits): auto-derive senior-citizen/customer-segment, fix disbursement display and reversed-transaction balances
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>
2026-08-26 18:58:20 +05:30

527 lines
18 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';
export type RdPeriodUnit = 'Days' | 'Months' | 'Years';
export interface SlabMatchResult {
slab: any;
slabIndex: number;
appliedRate: number;
maturityAmount?: number;
interestEarned?: number;
tenureDays?: number;
daysInYear?: number;
}
/**
* Recurring Deposits Account Interest Rate Chart Step
*/
@Component({
selector: 'mifosx-recurring-deposits-account-interest-rate-chart-step',
templateUrl: './recurring-deposits-account-interest-rate-chart-step.component.html',
styleUrls: ['./recurring-deposits-account-interest-rate-chart-step.component.scss'],
animations: [
trigger('expandChartSlab', [
state('collapsed', style({ height: '0px', minHeight: '0' })),
state('expanded', style({ height: '*' })),
transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)'))
])
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
MatTable,
MatColumnDef,
MatHeaderCellDef,
MatHeaderCell,
MatCellDef,
MatCell,
FaIconComponent,
NgSwitch,
MatHeaderRowDef,
MatHeaderRow,
MatRowDef,
MatRow,
MatStepperPrevious,
MatStepperNext,
TitleCasePipe,
DateFormatPipe,
FormatNumberPipe,
YesnoPipe,
DecimalPipe
]
})
export class RecurringDepositsAccountInterestRateChartStepComponent implements OnChanges {
@Input() recurringDepositsAccountTemplate: any;
@Input() recurringDepositsAccountProductTemplate: any;
@Input() submittedOnDate: Date | null = null;
/** Active CBS base interest rate versions — when provided, overrides the product chart slabs */
@Input() cbsBaseRates: any[] = [];
/** Pre-populate tenure from existing account (edit mode) */
@Input() initialTenure: {
depositPeriod: number;
depositPeriodFrequencyId: number;
depositAmount?: number;
isSeniorCitizen?: boolean;
appliedRate?: number;
} | null = null;
@Output() tenureSelected = new EventEmitter<{
depositPeriod: number;
depositPeriodFrequencyId: number;
depositAmount?: number;
maturityAmount?: number;
interestEarned?: number;
daysInYear?: number;
appliedRate?: number;
isSeniorCitizen?: boolean;
}>();
get activeBase(): any {
return (this.cbsBaseRates || []).find((r) => r.fdType === 'DOUBLE' && r.isActive);
}
get interestRateChartData(): any[] {
if (this.activeBase?.slabs?.length) {
return this.normalizeCbsSlabs(this.activeBase.slabs);
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (!template) return [];
return (
template.accountChart?.chartSlabs ??
template.activeChart?.chartSlabs ??
template.interestRateCharts?.[0]?.chartSlabs ??
template.interestRateChart?.chartSlabs ??
template.chartSlabs ??
[]
);
}
private normalizeCbsSlabs(slabs: any[]): any[] {
return (slabs || []).map((s) => ({
...s,
periodType: { id: s.periodTypeEnum, value: s.periodTypeLabel }
}));
}
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
}
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 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.';
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
};
if (this.submittedOnDate) {
const date = new Date(this.submittedOnDate);
calculationData.submittedOnDate = date.toISOString().split('T')[0];
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;
this.matchResult = { ...result };
this.emitTenureSelection();
});
} else {
this.matchResult = result;
this.emitTenureSelection();
}
}
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 ──────────────────────────────────────────────────────
get chartName(): string {
if (this.activeBase) {
return this.activeBase.versionLabel || 'CBS Base Interest Rate';
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.name ||
template?.activeChart?.name ||
template?.interestRateCharts?.[0]?.name ||
template?.interestRateChart?.name ||
template?.name ||
''
);
}
get chartFromDate(): any {
if (this.activeBase) {
return this.activeBase.effectiveFromDate;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.fromDate ||
template?.activeChart?.fromDate ||
template?.interestRateCharts?.[0]?.fromDate ||
template?.interestRateChart?.fromDate ||
template?.fromDate
);
}
get chartEndDate(): any {
if (this.activeBase) {
return null;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.endDate ||
template?.activeChart?.endDate ||
template?.interestRateCharts?.[0]?.endDate ||
template?.interestRateChart?.endDate ||
template?.endDate
);
}
get chartDescription(): string {
if (this.activeBase) {
return this.activeBase.remarks || '';
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.description ||
template?.activeChart?.description ||
template?.interestRateCharts?.[0]?.description ||
template?.interestRateChart?.description ||
template?.description ||
''
);
}
get isActive(): boolean {
if (this.activeBase) {
return this.activeBase.isActive;
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
return (
template?.accountChart?.isActive ??
template?.activeChart?.isActive ??
template?.interestRateCharts?.[0]?.isActive ??
template?.interestRateChart?.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
);
}
}