diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html
index 018b95331..00f425f6e 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html
@@ -51,12 +51,53 @@
}
+
+ Closure Reason
+
+ @for (reason of closureReasonOptions; track reason.id) {
+ {{ reason.label }}
+ }
+
+
+
@if (loadingAmount) {
Calculating premature-closure amount...
} @else {
+ @if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
+
+ @if (data.activatedOnDate) {
+
+ Current Term Started
+ {{ activatedOnDateDisplay }}
+
+
+ Tenure Completed (this term)
+ {{ tenureElapsedDisplay }} ({{ daysElapsed }} days)
+
+ }
+ @if (data.maturityDate) {
+
+ Scheduled Maturity Date
+ {{ maturityDateDisplay }}
+ ({{ daysRemainingToMaturity }} days left)
+
+ }
+ @if (data.currentBalance !== undefined && data.currentBalance !== null) {
+
+ Current Balance (as of today)
+ {{ data.currentBalance | formatNumber }}
+
+ }
+
+ }
+
@if (maturityAmount !== null && maturityAmount !== undefined) {
@if (data.depositAmount) {
@@ -71,12 +112,27 @@
{{ data.nominalAnnualInterestRate }}%
}
+ @if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
+
+ Effective Rate for this closure
+ {{ effectiveInterestRate }}%
+
+ }
@if (data.preClosurePenalApplicable) {
-
+
Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})
- − {{ data.preClosurePenalInterest }}%
+
+ @if (prematureCloseForm.get('waivePreClosurePenalty').value) {
+ Waived
+ } @else {
+ − {{ data.preClosurePenalInterest }}%
+ }
+
}
@if (interestEarned !== null) {
@@ -94,6 +150,26 @@
}
+ @if (data.preClosurePenalApplicable) {
+ @if (isAutoWaivedReason) {
+
+
+
+ Penalty automatically waived -- {{ selectedReasonLabel }} always waives the
+ pre-closure penalty, regardless of this checkbox.
+
+
+ } @else {
+
+ Waive the pre-closure penalty for this closure
+
+ Interest recalculates at the full contracted rate instead of the penalty rate above. This does not
+ change the product's own penalty setting -- it applies to this one closure only.
+
+
+ }
+ }
+
{{ 'labels.inputs.Action' | translate }}
@for (closureOption of onAccountClosureOptions; track closureOption) {
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss
index 289c1380e..ad41732d2 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.scss
@@ -204,6 +204,86 @@
font-weight: 700;
color: #0f172a;
}
+
+ &-sub {
+ font-family: 'DM Sans', sans-serif;
+ font-size: 11px;
+ font-weight: 500;
+ color: #94a3b8;
+ margin-left: 4px;
+ }
+}
+
+.pcd__preview--tenure {
+ margin-bottom: 14px;
+}
+
+.pcd__preview-row--waived {
+ .pcd__preview-label {
+ color: #16a34a;
+ }
+}
+
+.pcd__penalty-waived-tag {
+ font-family: 'DM Sans', sans-serif;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #15803d;
+ background: #dcfce7;
+ border: 1px solid #86efac;
+ border-radius: 999px;
+ padding: 3px 10px;
+}
+
+.pcd__waive-checkbox {
+ display: flex;
+ align-items: flex-start;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 12px;
+ padding: 14px 16px;
+ margin: 0 0 18px;
+ font-size: 13px;
+ font-weight: 600;
+ color: #0f172a;
+
+ ::ng-deep .mdc-form-field {
+ align-items: flex-start;
+ }
+
+ ::ng-deep .mdc-checkbox {
+ margin-top: -9px;
+ }
+}
+
+.pcd__waive-locked {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-left: 4px solid #16a34a;
+ border-radius: 10px;
+ color: #15803d;
+ font-size: 13px;
+ line-height: 1.5;
+ padding: 12px 16px;
+ margin: 0 0 18px;
+
+ strong {
+ color: #166534;
+ }
+}
+
+.pcd__waive-hint {
+ display: block;
+ font-size: 12px;
+ font-weight: 500;
+ color: #64748b;
+ margin-top: 3px;
+ line-height: 1.4;
}
.pcd__section-label {
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts
index 2122653e5..fbb1dab00 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.ts
@@ -67,6 +67,29 @@ export class PrematureCloseDialogComponent implements OnInit {
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
};
+ /**
+ * Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
+ * four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
+ * discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
+ * to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
+ * are gated because they are the product's own opt-in facilities (see SavingsProduct
+ * deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
+ * a maker pick it to bypass a penalty that product's terms don't actually exempt.
+ */
+ private static readonly NORMAL_REASON_ID = 100;
+ private static readonly ALL_CLOSURE_REASONS: {
+ id: number;
+ label: string;
+ autoWaived: boolean;
+ gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
+ }[] = [
+ { id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
+ { id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
+ { id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
+ { id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
+ { id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
+ ];
+
dialogRef = inject
>(MatDialogRef);
data: {
accountId: number | string;
@@ -76,6 +99,11 @@ export class PrematureCloseDialogComponent implements OnInit {
preClosurePenalApplicable?: boolean;
preClosurePenalInterest?: number;
preClosurePenalInterestOnType?: { value: string };
+ deceasedSettlement?: boolean;
+ noPenaltyNomineeHeirClosure?: boolean;
+ activatedOnDate?: any;
+ maturityDate?: any;
+ currentBalance?: number;
} = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
@@ -86,6 +114,10 @@ export class PrematureCloseDialogComponent implements OnInit {
onAccountClosureOptions: any[] = [];
savingsAccountsData: any[] = [];
maturityAmount: number = null;
+ /** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
+ * penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
+ * account's unmodified contracted rate. Read fresh on every recalculation. */
+ effectiveInterestRate: number = null;
loadingAmount = true;
loadFailed = false;
submitting = false;
@@ -99,17 +131,66 @@ export class PrematureCloseDialogComponent implements OnInit {
closedOnDate: [
this.maxDate,
Validators.required
- ]
+ ],
+ // Both always present (not added conditionally like onAccountClosureId below) so submit()
+ // always carries a value, even for a product with no penalty configured at all -- the backend
+ // ignores them there anyway, since there is nothing to waive.
+ prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
+ waivePreClosurePenalty: [false]
});
this.prematureCloseForm
.get('closedOnDate')
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
+ // Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
+ // should see the actual amount they're about to confirm, not the penalty-applied one, the moment
+ // they tick the box.
+ this.prematureCloseForm
+ .get('waivePreClosurePenalty')
+ .valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
+ // Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
+ // like it, recalculates the preview so the amount shown always matches the reason selected.
+ this.prematureCloseForm
+ .get('prematureClosureReasonId')
+ .valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
// Same date the form already starts with -- without this the amount/options never load unless the
// maker happens to touch the date picker, which the full-page form this replaces required too.
this.calculatePrematureAmount(this.maxDate);
}
+ /** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
+ get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
+ return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
+ (reason) => !reason.gatedBy || this.data[reason.gatedBy]
+ );
+ }
+
+ /** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
+ * hidden in favour of a plain statement of that fact when this is true (see the template). */
+ get isAutoWaivedReason(): boolean {
+ return !!this.selectedReason?.autoWaived;
+ }
+
+ get selectedReasonLabel(): string {
+ return this.selectedReason?.label ?? '';
+ }
+
+ private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
+ const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
+ return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
+ }
+
+ private onClosureReasonChange(reasonId: number): void {
+ const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
+ if (reason?.autoWaived) {
+ // emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
+ // change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
+ // and the amount would be calculated twice for one reason change.
+ this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
+ }
+ this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
+ }
+
private calculatePrematureAmount(date: Date): void {
this.loadingAmount = true;
this.loadFailed = false;
@@ -118,7 +199,9 @@ export class PrematureCloseDialogComponent implements OnInit {
const data = {
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
dateFormat,
- locale
+ locale,
+ prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
+ waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
@@ -127,6 +210,7 @@ export class PrematureCloseDialogComponent implements OnInit {
this.savingsAccountsData = response.savingsAccounts ?? [];
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
this.maturityAmount = response.maturityAmount;
+ this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
this.loadingAmount = false;
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
@@ -166,6 +250,94 @@ export class PrematureCloseDialogComponent implements OnInit {
return Number(this.maturityAmount) - Number(this.data.depositAmount);
}
+ get activatedOnDateDisplay(): string {
+ return this.formatFullDate(this.data.activatedOnDate);
+ }
+
+ get maturityDateDisplay(): string {
+ return this.formatFullDate(this.data.maturityDate);
+ }
+
+ /**
+ * The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
+ * Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
+ * clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
+ * advances it), and the backend's interest calculation is measured from THIS date, not today's actual
+ * date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
+ * interest the backend actually computed for the same closure -- both must read off the same clock.
+ */
+ private get referenceDate(): Date {
+ const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
+ return picked instanceof Date ? picked : this.maxDate;
+ }
+
+ /** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
+ * closure date is somehow before it, rather than a negative. */
+ get daysElapsed(): number | null {
+ const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
+ if (!start) {
+ return null;
+ }
+ return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
+ }
+
+ /** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
+ * than negative, since this dialog is for a still-Active account closing early, not a matured one. */
+ get daysRemainingToMaturity(): number | null {
+ const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
+ if (!maturity) {
+ return null;
+ }
+ return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
+ }
+
+ /** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
+ * count that is hard to read for a multi-year FD. */
+ get tenureElapsedDisplay(): string {
+ const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
+ if (!start) {
+ return '—';
+ }
+ const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
+ const parts: string[] = [];
+ if (years > 0) {
+ parts.push(`${years} yr${years === 1 ? '' : 's'}`);
+ }
+ if (months > 0) {
+ parts.push(`${months} mo${months === 1 ? '' : 's'}`);
+ }
+ if (days > 0 || parts.length === 0) {
+ parts.push(`${days} day${days === 1 ? '' : 's'}`);
+ }
+ return parts.join(', ');
+ }
+
+ private formatFullDate(value: any): string {
+ if (!value) {
+ return '—';
+ }
+ const parsed = this.dateUtils.parseDate(value);
+ // "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
+ // account's own configured short date format elsewhere in the app.
+ return this.dateUtils.formatDate(parsed, 'd MMMM y');
+ }
+
+ private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
+ let years = end.getFullYear() - start.getFullYear();
+ let months = end.getMonth() - start.getMonth();
+ let days = end.getDate() - start.getDate();
+ if (days < 0) {
+ months -= 1;
+ const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
+ days += daysInPreviousMonth;
+ }
+ if (months < 0) {
+ years -= 1;
+ months += 12;
+ }
+ return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
+ }
+
/** True when the chosen action needs a destination account but the customer has none to pick. */
get noDestinationAccounts(): boolean {
return (
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
index a07dde825..9ebae159f 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
@@ -298,6 +298,18 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
+ /**
+ * The start date of the term currently running -- the last entry in renewalHistory, which is ordered
+ * oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
+ * an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
+ * in that case anyway.
+ */
+ private get currentTermStartDate(): any {
+ const history = this.fixedDepositsAccountData?.renewalHistory;
+ const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
+ return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
+ }
+
/**
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
* matured yet.
@@ -316,7 +328,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
- preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType
+ preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
+ // Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
+ // facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
+ // a product that never enabled them shouldn't offer a reason implying it did.
+ deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
+ noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
+ // Tenure context (current term's start date, maturity date, current balance) so the dialog can
+ // show how far into THIS term the account is and how much is actually sitting in it today, not
+ // just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
+ // term's own start (see currentTermStartDate), not the account's original activation -- a
+ // renewed FD's interest/tenure runs from the renewal, and showing the original activation date
+ // here would make an account renewed days ago look like it has been running for months.
+ activatedOnDate: this.currentTermStartDate,
+ maturityDate: this.fixedDepositsAccountData.maturityDate,
+ currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
}
});
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
index 3648e2ee1..885983e11 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
@@ -41,8 +41,8 @@
) {
-
- @if (term.renewalstatus === 'ACTIVE') {
+
+ @if (isTermActive(term, last)) {
}
@@ -51,15 +51,15 @@
}
-
+
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
-
-
- {{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
+
+
+ {{ termStatusLabel(term, last) }}
@if (renewalTypeLabel(term)) {
@@ -95,7 +95,7 @@
{{
- term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
+ isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
}}
{{
@@ -106,9 +106,7 @@
- {{
- term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
- }}
+ {{ maturityLabel(term, last) }}
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts
index 34bd1bb2b..b58372aa5 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
}
+ /**
+ * Whether one term's card should show as "still running". `term.renewalstatus` only ever flips from
+ * ACTIVE to MATURED when a term rolls into its successor (see FixedDepositRenewalHistory#markMatured
+ * on the backend) -- a premature closure never touches that row, so the latest term's archived status
+ * would read ACTIVE forever even after the whole account has closed. For the LAST term only, this
+ * cross-checks the account's own live status instead of trusting the archived flag; every earlier
+ * term is reliably matured already (something renewed past it), so its own renewalstatus is
+ * trustworthy as-is and doesn't need the live account status at all.
+ */
+ isTermActive(term: any, isLast: boolean): boolean {
+ if (isLast && this.fixedDepositsAccountData?.status) {
+ return !!this.fixedDepositsAccountData.status.active;
+ }
+ return term?.renewalstatus === 'ACTIVE';
+ }
+
+ /** Badge text for one term's card -- see {@link isTermActive} for why the last term needs its own check. */
+ termStatusLabel(term: any, isLast: boolean): string {
+ if (this.isTermActive(term, isLast)) {
+ return 'Active';
+ }
+ if (isLast && this.fixedDepositsAccountData?.status?.prematureClosed) {
+ return 'Premature Closed';
+ }
+ return 'Matured';
+ }
+
+ /**
+ * Label for the maturity/value figure -- "Projected Maturity" only makes sense while the term is
+ * still genuinely running towards that projection. `term.maturedamount` (the archived settled value)
+ * is only ever written when a term rolls into its successor, same gap as {@link isTermActive}: a
+ * premature closure settles the account without ever touching that column, so the last term would
+ * keep reading as a live projection forever even though the figure showing is already the final,
+ * settled one (see {@link maturityFor}).
+ */
+ maturityLabel(term: any, isLast: boolean): string {
+ if (term?.maturedamount != null) {
+ return 'Matured At';
+ }
+ if (isLast && !this.isTermActive(term, isLast)) {
+ return 'Closed At';
+ }
+ return 'Projected Maturity';
+ }
+
/**
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
*