From f0e059c6211fb2f5590df966fd4ec7fcdba25228 Mon Sep 17 00:00:00 2001 From: Nitesh Yadav Date: Sun, 23 Aug 2026 16:46:22 +0530 Subject: [PATCH] feat(deposits): FD Renewal History tab, redesigned Payment action, and renewal-aware Transactions/certificate WHAT THIS CHANGES ================== Frontend counterpart to the backend FD in-place renewal, senior-citizen rate, and Payment action work. 1. RENEWAL HISTORY TAB (new) -- fixed-deposit-account-view/renewal-history-tab/. A vertical timeline, one card per term of the FD, oldest first. Backend data arrives as a list of lowercase-keyed maps (Postgres unquoted SQL aliases), which the component normalises. Includes term-rate-chart-dialog, a popup showing one term's full rate-by-tenure chart, including the senior-citizen column, with an "Applied" badge on the rate that was actually used. Route added in fixed-deposits-routing.module.ts, tab link added in fixed-deposit-account-view.component.html (shown only when the account has renewal history). 2. PAYMENT DIALOG (new) -- fixed-deposit-account-view/custom-dialogs/payment-dialog/. Card-based picker for the four supported closure types (Transfer to Savings, Re-Invest Principal and Interest, Re-Invest Principal Only, Partial Re-Invest), with Withdraw Deposit filtered out client-side. Fetches its own template via a new fixedDepositsService method (getFixedDepositsAccountPaymentTemplate, command=payment) rather than reusing the Close template, since the Payment template is the only one the backend includes PARTIAL_REINVEST in. The Partial Re-Invest amount cap is date-dependent: the backend bounds it against the account balance AFTER posting interest to the chosen closed-on date, which lands on the maturity amount on or after maturity and on the current balance before it. The dialog mirrors this (reinvestCap / processingOnOrAfterMaturity) so the shown split actually adds up and the client-side validator agrees with the server. This was wrong in an earlier pass of this work (compared against a fixed figure regardless of date) and was caught and fixed via a real reconciliation check (77000 + 23000 did not equal the displayed total) before this commit. Added: a loading-failure state (previously the spinner ran forever on a failed template fetch), an inline submit-error message (previously a failed POST left the dialog open with no feedback), an empty-savings- accounts hint, and accessibility attributes on the option cards (role="radio", aria-checked, a visible check icon, focus-visible outline) since selection was previously colour-only. 3. TRANSACTIONS TAB -- merges synthetic "FD Renewed" marker rows into the real transaction list, since an in-place renewal posts no actual ledger transaction and would otherwise look like the balance changed for no reason. 4. INTEREST RATE CHART TAB -- added the senior-citizen rate column. 5. DEPOSIT CERTIFICATE DIALOG -- lists the full renewal history on FD certificates. renewalCount is renewalHistory.length - 1 (the first row is the original term, not a renewal); an earlier version of this used the raw row count and overstated the renewal count by one on a customer-facing document, caught and fixed before this commit. 6. CLIENTS GENERAL TAB -- fixedDepositAccountsForDisplay folds a renewed FD's superseded terms out of the account list (each renewal used to show as two unrelated accounts, one Closed and one Active). Memoised on the source array's identity so it does not rebuild, and rebind the table, on every change-detection tick. Only rewrites FD rows; Savings, RD, Current, and Share account rows pass through this getter untouched, and the RD/Savings sections of the template were left bound directly to the original savingAccounts array. KNOWN CAVEATS ============= - This depends on the paired backend commit (same date, "feat(deposits): FD in-place renewal, senior-citizen rate persistence, and Payment action"). Deploying this frontend without that backend change means the Payment dialog and Renewal History tab will call endpoints that do not exist yet. - Renewal-history rows rely on the backend returning lowercase column keys (a Postgres-specific behaviour of unquoted SQL aliases via JdbcTemplate). Confirmed correct against this project's Postgres dev database; if this project ever runs on a different database engine, that assumption would need re-checking. - src/environments/.env.ts is intentionally excluded from this commit; it is a local build-version stamp file that should never be committed. Co-Authored-By: Claude Sonnet 5 --- .../general-tab/general-tab.component.html | 21 +- .../general-tab/general-tab.component.scss | 14 + .../general-tab/general-tab.component.ts | 52 ++- .../payment-dialog.component.html | 191 +++++++++ .../payment-dialog.component.scss | 296 ++++++++++++++ .../payment-dialog.component.ts | 288 ++++++++++++++ .../fixed-deposit-account-view.component.html | 15 +- .../fixed-deposit-account-view.component.ts | 29 ++ .../fixed-deposits-buttons.config.ts | 3 + .../interest-rate-chart-tab.component.html | 11 + .../interest-rate-chart-tab.component.ts | 1 + .../renewal-history-tab.component.html | 139 +++++++ .../renewal-history-tab.component.scss | 364 ++++++++++++++++++ .../renewal-history-tab.component.ts | 235 +++++++++++ .../term-rate-chart-dialog.component.html | 77 ++++ .../term-rate-chart-dialog.component.scss | 159 ++++++++ .../term-rate-chart-dialog.component.ts | 93 +++++ .../transactions-tab.component.html | 70 ++-- .../transactions-tab.component.scss | 28 ++ .../transactions-tab.component.ts | 68 +++- .../fixed-deposits-routing.module.ts | 10 + .../fixed-deposits/fixed-deposits.service.ts | 11 + .../deposit-certificate-dialog.component.html | 46 ++- .../deposit-certificate-dialog.component.scss | 44 +++ .../deposit-certificate-dialog.component.ts | 27 +- 25 files changed, 2256 insertions(+), 36 deletions(-) create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss create mode 100644 src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.html b/src/app/clients/clients-view/general-tab/general-tab.component.html index 2123fb7ae..242346b4e 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.html +++ b/src/app/clients/clients-view/general-tab/general-tab.component.html @@ -435,12 +435,17 @@ @if (!showClosedFixedAccounts) {
- +
@@ -524,12 +529,20 @@ @if (showClosedFixedAccounts) {
-
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + }
+
diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.scss b/src/app/clients/clients-view/general-tab/general-tab.component.scss index 11848e360..f913a9808 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.scss +++ b/src/app/clients/clients-view/general-tab/general-tab.component.scss @@ -209,6 +209,20 @@ $red: #d93025; gap: 0; } +// Marks an FD row that is a renewal of an earlier term, since the row shows the original FD number +// rather than this term's own account number. +.fd-renewal-badge { + display: inline-block; + margin-left: 8px; + padding: 1px 7px; + border-radius: 10px; + background-color: $gray-200; + color: $gray-700; + font-size: 11px; + font-weight: 500; + white-space: nowrap; +} + // ── Table Wrapper ── .table-wrapper { border-radius: 0 0 8px 8px; diff --git a/src/app/clients/clients-view/general-tab/general-tab.component.ts b/src/app/clients/clients-view/general-tab/general-tab.component.ts index d957b00e2..333c43931 100644 --- a/src/app/clients/clients-view/general-tab/general-tab.component.ts +++ b/src/app/clients/clients-view/general-tab/general-tab.component.ts @@ -234,7 +234,57 @@ export class GeneralTabComponent implements OnDestroy { } get fixedDepositCount(): number { - return this.savingAccounts.filter((a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit').length; + return this.fixedDepositAccountsForDisplay.filter( + (a: any) => a.depositType?.code === 'depositAccountType.fixedDeposit' + ).length; + } + + /** + * Accounts for the Fixed Deposit sections, with superseded renewal cycles folded away. + * + * An FD renewal closes one account and opens the next term as its own account row, so a renewed FD + * would otherwise appear twice on this page -- the matured term under Closed and the new term under + * Active -- reading as two unrelated deposits. A closed term is therefore dropped whenever a later + * cycle of the same FD (same `fdAccountNumber`) exists, leaving one row per FD that shows its + * current term. A chain that has genuinely ended keeps its final term, so it still lists as closed. + * + * Only FD rows are rewritten; savings, RD, current and share accounts pass through untouched. + */ + get fixedDepositAccountsForDisplay(): any[] { + // Memoised on the source array's identity. This getter is bound straight to two [dataSource] inputs, so + // returning a freshly built array on every change-detection pass would make the table re-bind and + // re-render its rows on every tick -- mouse moves included. + if (this.fdDisplayCacheSource !== this.savingAccounts) { + this.fdDisplayCacheSource = this.savingAccounts; + this.fdDisplayCache = this.foldSupersededFdCycles(this.savingAccounts); + } + return this.fdDisplayCache; + } + + private fdDisplayCacheSource: any[] = null; + private fdDisplayCache: any[] = []; + + private foldSupersededFdCycles(accounts: any[]): any[] { + const latestCycleByFd = new Map(); + for (const account of accounts) { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + continue; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + if (cycle > (latestCycleByFd.get(fdNumber) ?? -1)) { + latestCycleByFd.set(fdNumber, cycle); + } + } + + return accounts.filter((account: any) => { + if (account?.depositType?.code !== 'depositAccountType.fixedDeposit') { + return true; + } + const fdNumber = account.fdAccountNumber ?? account.accountNo; + const cycle = account.renewalCycleNo ?? 0; + return cycle === latestCycleByFd.get(fdNumber); + }); } get recurringDepositCount(): number { diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html new file mode 100644 index 000000000..659f79528 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html @@ -0,0 +1,191 @@ + + +
+
+ +
+

{{ 'labels.heading.Payment' | translate }}

+

Process this Fixed Deposit now -- works whether it's Active or Matured.

+
+
+ +
+ @if (loadingTemplate) { +
+ +
+ } @else if (loadFailed) { + + } @else { +
+ @if (reinvestCap !== null && reinvestCap !== undefined) { +
+ + {{ + remainderIsApproximate + ? 'Amount available on the selected date' + : ('labels.inputs.Maturity Amount' | translate) + }} + + {{ reinvestCap | formatNumber }} +
+ } + + + {{ 'labels.inputs.Closed On Date' | translate }} + + + + @if (paymentForm.controls.closedOnDate.hasError('required')) { + + {{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + + +
+ @for (closureOption of onAccountClosureOptions; track closureOption) { + + } +
+ @if ( + paymentForm.controls.onAccountClosureId.hasError('required') && + paymentForm.controls.onAccountClosureId.touched + ) { +
+ {{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} +
+ } + + @if (isPartialReinvest) { +
+ + {{ 'labels.inputs.Reinvest Amount' | translate }} + + @if (paymentForm.controls.reinvestAmount?.hasError('required')) { + + {{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + @if (paymentForm.controls.reinvestAmount?.hasError('min')) { + {{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }} + } + @if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) { + {{ + 'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate + }} + } + + +
+ {{ 'labels.inputs.Remainder to Transfer' | translate }} + {{ + remainderAmount !== null ? (remainderAmount | formatNumber) : '—' + }} +
+
+ @if (remainderIsApproximate) { +

+ + The selected date is before maturity. Interest up to that date is posted first, so the amount actually + transferred will be at least the figure shown. +

+ } + } + + @if (noDestinationAccounts) { + + } + + @if (paymentForm.contains('toSavingsAccountId')) { + + {{ 'labels.inputs.Transfer to Savings' | translate }} + + @for (account of savingsAccountsData; track account) { + + {{ account.accountNo }} + + } + + @if (paymentForm.controls.toSavingsAccountId?.hasError('required')) { + + {{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }} + {{ 'labels.commons.required' | translate }} + + } + + } + + @if (paymentForm.contains('transferDescription')) { + + {{ 'labels.inputs.Transfer Description' | translate }} + + + } + + + {{ 'labels.inputs.Note' | translate }} + + + + @if (submitError) { + + } + + } +
+ +
+ + +
+
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss new file mode 100644 index 000000000..48967437c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss @@ -0,0 +1,296 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$blue: #1e40af; +$blue-soft: #eff6ff; +$green: #166534; +$green-soft: #f0fdf4; + +.pmt { + display: flex; + flex-direction: column; + min-height: 320px; + max-height: 85vh; + overflow: hidden; + margin: -24px; +} + +.pmt__header { + display: flex; + align-items: flex-start; + gap: 14px; + padding: 22px 28px 18px; + background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%); + color: #fff; + flex: 0 0 auto; +} + +.pmt__header-ico { + width: 40px; + height: 40px; + min-width: 40px; + border-radius: 10px; + background: rgb(255 255 255 / 15%); + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; +} + +.pmt__title { + margin: 0; + font-size: 19px; + font-weight: 700; +} + +.pmt__subtitle { + margin: 4px 0 0; + font-size: 12.5px; + color: rgb(255 255 255 / 82%); + line-height: 1.4; +} + +.pmt__content { + padding: 22px 28px 8px !important; + overflow-y: auto; + flex: 1 1 auto; +} + +.pmt__loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 260px; +} + +.pmt__stat { + display: flex; + flex-direction: column; + gap: 2px; + background: $blue-soft; + border: 1px solid #dbeafe; + border-radius: 10px; + padding: 12px 16px; + margin-bottom: 18px; +} + +.pmt__stat-label { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $blue; +} + +.pmt__stat-value { + font-size: 22px; + font-weight: 800; + color: $navy; + font-variant-numeric: tabular-nums; +} + +.pmt__date { + width: 100%; +} + +.pmt__section-label { + font-size: 11.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $slate; + margin: 4px 0 10px; +} + +.pmt__options { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); + gap: 10px; + margin-bottom: 6px; +} + +.pmt__option { + display: flex; + align-items: flex-start; + gap: 10px; + text-align: left; + background: #fff; + border: 1.5px solid $border; + border-radius: 10px; + padding: 12px 14px; + cursor: pointer; + font: inherit; + transition: + border-color 0.15s, + background 0.15s, + box-shadow 0.15s; +} + +.pmt__option:hover { + border-color: #93c5fd; + background: $blue-soft; +} + +.pmt__option--active { + border-color: $blue; + background: $blue-soft; + box-shadow: 0 0 0 1px $blue; +} + +.pmt__option-ico { + width: 30px; + height: 30px; + min-width: 30px; + border-radius: 8px; + background: $soft; + color: $blue; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; +} + +.pmt__option--active .pmt__option-ico { + background: $blue; + color: #fff; +} + +.pmt__option-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.pmt__option-title { + font-size: 13px; + font-weight: 700; + color: $navy; +} + +.pmt__option-desc { + font-size: 11px; + color: $muted; + line-height: 1.35; +} + +.pmt__option:focus-visible { + outline: 2px solid $blue; + outline-offset: 2px; +} + +.pmt__option-check { + margin-left: auto; + color: $blue; + font-size: 13px; +} + +.pmt__option-error { + color: #b91c1c; + font-size: 12px; + margin: 4px 0 14px; +} + +.pmt__error { + display: flex; + align-items: flex-start; + gap: 4px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 8px; + color: #b91c1c; + font-size: 12.5px; + line-height: 1.45; + padding: 10px 14px; + margin: 4px 0 8px; +} + +.pmt__hint { + display: flex; + align-items: flex-start; + gap: 4px; + background: $soft; + border: 1px solid $border; + border-radius: 8px; + color: $slate; + font-size: 12px; + line-height: 1.45; + padding: 9px 13px; + margin: 0 0 10px; +} + +.pmt__hint--warn { + background: #fffbeb; + border-color: #fde68a; + color: #92400e; +} + +.pmt__split { + display: flex; + align-items: center; + gap: 12px; + background: $soft; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 14px 16px 0; + margin: 4px 0 8px; + flex-wrap: wrap; +} + +.pmt__split-input { + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-arrow { + color: $muted; + font-size: 14px; + margin-bottom: 20px; +} + +.pmt__split-remainder { + display: flex; + flex-direction: column; + gap: 2px; + background: $green-soft; + border: 1px solid #bbf7d0; + border-radius: 8px; + padding: 8px 14px; + margin-bottom: 14px; + flex: 1 1 180px; + min-width: 160px; +} + +.pmt__split-remainder-label { + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $green; +} + +.pmt__split-remainder-value { + font-size: 16px; + font-weight: 800; + color: $green; + font-variant-numeric: tabular-nums; +} + +.pmt__content mat-form-field { + width: 100%; +} + +.pmt__actions { + padding: 14px 28px 20px !important; + border-top: 1px solid $border; + flex: 0 0 auto; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts new file mode 100644 index 000000000..cd433f83c --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts @@ -0,0 +1,288 @@ +/** + * 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/. + */ + +/** Angular Imports */ +import { Component, OnInit, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { CdkScrollable } from '@angular/cdk/scrolling'; +import { CdkTextareaAutosize } from '@angular/cdk/text-field'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn, Validators } from '@angular/forms'; + +/** Custom Services */ +import { FixedDepositsService } from '../../../fixed-deposits.service'; +import { SettingsService } from 'app/settings/settings.service'; +import { Dates } from 'app/core/utils/dates'; +import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and + * Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice, + * Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the + * entered amount renews on this same account, the rest transfers to a savings account picked here. + */ +@Component({ + selector: 'mifosx-payment-dialog', + templateUrl: './payment-dialog.component.html', + styleUrls: ['./payment-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + CdkScrollable, + MatDialogContent, + MatDialogActions, + MatDialogClose, + CdkTextareaAutosize, + MatProgressSpinnerModule, + FaIconComponent, + FormatNumberPipe + ] +}) +export class PaymentDialogComponent implements OnInit { + /** The `onAccountClosureId` codes each closure type is registered under on the backend. */ + private static readonly WITHDRAW_DEPOSIT_ID = 100; + private static readonly TRANSFER_TO_SAVINGS_ID = 200; + private static readonly REINVEST_FULL_ID = 300; + private static readonly REINVEST_PRINCIPAL_ID = 400; + private static readonly PARTIAL_REINVEST_ID = 500; + + private static readonly ACTION_ICONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'sync-alt', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: 'piggy-bank', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'arrows-alt-h' + }; + + private static readonly ACTION_DESCRIPTIONS: Record = { + [PaymentDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash', + [PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account', + [PaymentDialogComponent.REINVEST_FULL_ID]: 'Renew the full amount, same account number', + [PaymentDialogComponent.REINVEST_PRINCIPAL_ID]: + 'Renew only the principal; interest transferred to a savings account', + [PaymentDialogComponent.PARTIAL_REINVEST_ID]: 'Renew part of it here; transfer the rest' + }; + + dialogRef = inject>(MatDialogRef); + data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA); + private formBuilder = inject(UntypedFormBuilder); + private fixedDepositsService = inject(FixedDepositsService); + private settingsService = inject(SettingsService); + private dateUtils = inject(Dates); + + paymentForm: UntypedFormGroup; + /** Loaded once, on open, from the same closure template the existing Close action already uses. */ + onAccountClosureOptions: any[] = []; + savingsAccountsData: any[] = []; + maturityAmount: number = null; + loadingTemplate = true; + loadFailed = false; + submitting = false; + submitError: string = null; + maxDate: Date; + + ngOnInit(): void { + this.maxDate = this.settingsService.businessDate; + this.paymentForm = this.formBuilder.group({ + closedOnDate: [ + '', + Validators.required + ], + onAccountClosureId: [ + '', + Validators.required + ], + note: [''] + }); + this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id)); + // The cap depends on whether the chosen date is on or after maturity, so a date change can make an + // already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date. + this.paymentForm + .get('closedOnDate') + .valueChanges.subscribe(() => + this.paymentForm.get('reinvestAmount')?.updateValueAndValidity({ emitEvent: false }) + ); + + this.fixedDepositsService.getFixedDepositsAccountPaymentTemplate(this.data.accountId as string).subscribe({ + next: (response: any) => { + // Withdraw Deposit books cash out of the vault immediately, which isn't appropriate for a + // staff-triggered Payment that may run well before or after the actual counter transaction -- + // Transfer to Savings or a Reinvest option covers every real case instead. + this.onAccountClosureOptions = (response.onAccountClosureOptions ?? []).filter( + (option: any) => option.id !== PaymentDialogComponent.WITHDRAW_DEPOSIT_ID + ); + this.savingsAccountsData = response.savingsAccounts ?? []; + this.maturityAmount = response.maturityAmount; + this.loadingTemplate = false; + }, + // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out + // and no indication of why the form never appeared. + error: () => { + this.loadingTemplate = false; + this.loadFailed = true; + } + }); + } + + /** + * The amount being split, and the bound the entered partial amount is checked against. + *

+ * The server derives this from the account's balance AFTER it posts interest up to the chosen date, so which figure + * matches depends on that date. On or after maturity it posts the remaining full-rate interest, landing on the + * maturity amount. Before maturity it posts penalty-aware interest instead, which lands somewhere between the current + * balance and the maturity amount -- unknowable here, so the current balance is used as a floor that can never accept + * an amount the server would reject, and {@link remainderIsApproximate} tells the user the transfer will be larger. + */ + get reinvestCap(): number | null { + if (this.processingOnOrAfterMaturity) { + return this.maturityAmount ?? this.data.accountBalance ?? null; + } + return this.data.accountBalance ?? this.maturityAmount ?? null; + } + + /** Whether the chosen closure date is on or after the deposit's maturity date. */ + private get processingOnOrAfterMaturity(): boolean { + const chosen = this.paymentForm?.get('closedOnDate')?.value; + if (!chosen || !this.data.maturityDate) { + return false; + } + return this.toTime(chosen) >= this.toTime(this.data.maturityDate); + } + + /** Dates arrive here either as a Date from the picker or as the API's [yyyy, mm, dd] array. */ + private toTime(value: any): number { + if (value instanceof Date) { + return new Date(value.getFullYear(), value.getMonth(), value.getDate()).getTime(); + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + + /** True when the cap is the pre-maturity balance, so the figures shown are a floor rather than exact. */ + get remainderIsApproximate(): boolean { + return !this.processingOnOrAfterMaturity; + } + + iconFor(id: number): string { + return PaymentDialogComponent.ACTION_ICONS[id] ?? 'circle'; + } + + descriptionFor(id: number): string { + return PaymentDialogComponent.ACTION_DESCRIPTIONS[id] ?? ''; + } + + selectClosureType(id: number): void { + this.paymentForm.get('onAccountClosureId').setValue(id); + this.paymentForm.get('onAccountClosureId').markAsTouched(); + } + + /** Adds/removes the destination-account and partial-amount controls to match what the chosen type needs. */ + private onClosureTypeChange(id: number): void { + const needsDestinationAccount = + id === PaymentDialogComponent.TRANSFER_TO_SAVINGS_ID || + id === PaymentDialogComponent.PARTIAL_REINVEST_ID || + id === PaymentDialogComponent.REINVEST_PRINCIPAL_ID; + if (needsDestinationAccount) { + if (!this.paymentForm.contains('toSavingsAccountId')) { + this.paymentForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required)); + this.paymentForm.addControl('transferDescription', new UntypedFormControl('')); + } + } else { + this.paymentForm.removeControl('toSavingsAccountId'); + this.paymentForm.removeControl('transferDescription'); + } + + if (id === PaymentDialogComponent.PARTIAL_REINVEST_ID) { + if (!this.paymentForm.contains('reinvestAmount')) { + this.paymentForm.addControl( + 'reinvestAmount', + new UntypedFormControl('', [ + Validators.required, + Validators.min(0.01), + this.belowMaturityAmountValidator() + ]) + ); + } + } else { + this.paymentForm.removeControl('reinvestAmount'); + } + } + + /** Mirrors the backend's own `0 < reinvestAmount < accountBalance` rule, so a bad amount fails before submit. */ + private belowMaturityAmountValidator(): ValidatorFn { + return (control) => { + const cap = Number(this.reinvestCap); + if (!cap || control.value === null || control.value === '') { + return null; + } + return Number(control.value) >= cap ? { belowMaturityAmount: true } : null; + }; + } + + get isPartialReinvest(): boolean { + return this.paymentForm?.get('onAccountClosureId')?.value === PaymentDialogComponent.PARTIAL_REINVEST_ID; + } + + /** True when the chosen action needs a destination account but the customer has none to pick. */ + get noDestinationAccounts(): boolean { + return ( + !this.loadingTemplate && this.paymentForm?.contains('toSavingsAccountId') && this.savingsAccountsData.length === 0 + ); + } + + /** What leaves this FD when the entered partial amount is kept behind; null until both figures are known. */ + get remainderAmount(): number | null { + const reinvestAmount = this.paymentForm?.get('reinvestAmount')?.value; + if (!this.isPartialReinvest || reinvestAmount === null || reinvestAmount === '' || !this.reinvestCap) { + return null; + } + return Number(this.reinvestCap) - Number(reinvestAmount); + } + + submit(): void { + const formValue = { ...this.paymentForm.value }; + const locale = this.settingsService.language.code; + const dateFormat = this.settingsService.dateFormat; + if (formValue.closedOnDate instanceof Date) { + formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat); + } + const data = { ...formValue, dateFormat, locale }; + this.submitting = true; + this.submitError = null; + this.fixedDepositsService + .executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data) + .subscribe({ + next: () => { + this.dialogRef.close({ confirm: true }); + }, + // Keep the dialog open with the entered values and say what went wrong, rather than leaving a + // permanently disabled Confirm behind whatever modal the global interceptor threw up. + error: (error: any) => { + this.submitting = false; + this.submitError = + error?.error?.defaultUserMessage ?? + error?.error?.errors?.[0]?.defaultUserMessage ?? + 'This payment could not be processed. Please review the details and try again.'; + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html index 793cce9cb..0ac452a8e 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.html @@ -37,7 +37,7 @@ {{ 'labels.inputs.Account Number' | translate }} @@ -230,6 +230,19 @@ {{ 'labels.inputs.Transactions' | translate }} } + @if (fixedDepositsAccountData.renewalHistory?.length) { + + + {{ 'labels.inputs.Renewal History' | translate }} + + } { + if (response?.confirm) { + this.reload(); + } + }); + } + /** * Deletes Fixed Deposits Account. */ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts index 271987c9e..3146a730c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts @@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration { }, { name: 'Add Charge' + }, + { + name: 'Payment' } ]; break; diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html index 5e9de8301..bf81fc96f 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.html @@ -36,6 +36,17 @@

+ + + + + diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts index 7934d41b5..f400aa9f4 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/interest-rate-chart-tab/interest-rate-chart-tab.component.ts @@ -71,6 +71,7 @@ export class InterestRateChartTabComponent { 'period', 'amountRange', 'interest', + 'seniorCitizen', 'description', 'actions' ]; 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 new file mode 100644 index 000000000..3648e2ee1 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html @@ -0,0 +1,139 @@ + + +
+ @if (renewalHistory.length) { + +
+
+ FD Number + {{ fdNumber }} +
+
+ Terms So Far + {{ renewalHistory.length }} +
+
+ Original Principal + {{ originalPrincipal | currency: currency.code : 'symbol-narrow' }} +
+
+ Current Value + {{ currentValue | currency: currency.code : 'symbol-narrow' }} +
+
+ Lifetime Interest + {{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }} +
+
+ + +
+ } @else { +
+ +

{{ 'labels.messages.No Renewal History Available' | translate }}

+
+ } +
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss new file mode 100644 index 000000000..bb6e474cd --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.scss @@ -0,0 +1,364 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; +$accent: #2563eb; +$accent-soft: #eff6ff; + +.rh-container { + max-width: 860px; + margin: 0 auto; + padding: 4px 0 24px; +} + +/* ─── Summary strip ─────────────────────────────────────────────────── */ +.rh-summary { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 1px; + background: $border; + border: 1px solid $border; + border-radius: 14px; + overflow: hidden; + margin-bottom: 28px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + + &__item { + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 16px; + background: #fff; + } + + &__label { + font-size: 10.5px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.04em; + } + + &__value { + font-size: 15px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + } + + &__item--highlight { + background: $accent-soft; + + .rh-summary__value { + color: $accent; + } + } + + &__item--positive { + background: $positive-soft; + + .rh-summary__value { + color: $positive; + } + } +} + +@media (width <= 720px) { + .rh-summary { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* ─── Timeline rail ──────────────────────────────────────────────────── */ +.rh-timeline { + display: flex; + flex-direction: column; +} + +.rh-node { + display: flex; + gap: 18px; +} + +.rh-node__rail { + flex: none; + width: 20px; + display: flex; + flex-direction: column; + align-items: center; +} + +.rh-node__dot { + position: relative; + width: 16px; + height: 16px; + margin-top: 22px; + border-radius: 50%; + background: #cbd5e1; + border: 3px solid #fff; + box-shadow: 0 0 0 2px #cbd5e1; + flex: none; + + &--active { + background: $positive; + box-shadow: 0 0 0 2px $positive; + } +} + +.rh-node__pulse { + position: absolute; + inset: -6px; + border-radius: 50%; + border: 2px solid $positive; + opacity: 0.6; + animation: rh-pulse 1.8s ease-out infinite; +} + +@keyframes rh-pulse { + 0% { + transform: scale(0.6); + opacity: 0.7; + } + + 100% { + transform: scale(1.6); + opacity: 0; + } +} + +.rh-node__line { + flex: 1; + width: 2px; + min-height: 24px; + background: $border; + margin: 4px 0; +} + +/* ─── Term card ──────────────────────────────────────────────────────── */ +.rh-card { + flex: 1; + min-width: 0; + background: #fff; + border: 1px solid $border; + border-radius: 14px; + box-shadow: 0 1px 3px rgb(15 23 42 / 4%); + padding: 16px 18px; + margin-bottom: 20px; + transition: box-shadow 0.15s ease; + + &--active { + border-color: rgb(4 120 87 / 20%); + box-shadow: 0 2px 10px rgb(4 120 87 / 8%); + } + + &:hover { + box-shadow: 0 4px 14px rgb(15 23 42 / 8%); + } + + &__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + } + + &__title-group { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + + &__title { + margin: 0; + font-size: 15px; + font-weight: 700; + color: $navy; + } + + &__period { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; + font-size: 12.5px; + color: $slate; + font-weight: 500; + + &-ico { + color: $muted; + font-size: 11px; + } + + &-arrow { + color: $muted; + font-size: 10px; + } + } + + &__tenure { + color: $muted; + font-weight: 400; + } + + &__foot { + display: flex; + align-items: center; + gap: 6px; + margin-top: 12px; + padding-top: 10px; + border-top: 1px dashed $border; + font-size: 11.5px; + color: $muted; + + &-ico { + font-size: 10px; + } + + a { + color: $accent; + font-weight: 600; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + } +} + +/* ─── Status badge / auto-renewed tag ───────────────────────────────── */ +.rh-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $soft; + color: $muted; + font-size: 10.5px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + + &--active { + background: $positive-soft; + color: $positive; + } +} + +.rh-tag { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 9px; + border-radius: 999px; + background: $accent-soft; + color: $accent; + font-size: 10.5px; + font-weight: 600; +} + +/* ─── View Rate Chart button ─────────────────────────────────────────── */ +.rh-chart-btn { + display: inline-flex; + align-items: center; + gap: 7px; + padding: 6px 14px; + border: 1px solid $border; + border-radius: 999px; + background: #fff; + color: $accent; + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + + &:hover { + background: $accent-soft; + border-color: $accent; + } +} + +/* ─── Stat grid ──────────────────────────────────────────────────────── */ +.rh-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 14px; +} + +@media (width <= 640px) { + .rh-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.rh-stat { + display: flex; + flex-direction: column; + gap: 3px; + padding: 9px 12px; + background: $soft; + border-radius: 10px; + + &__label { + font-size: 10px; + font-weight: 600; + color: $muted; + text-transform: uppercase; + letter-spacing: 0.03em; + } + + &__value { + font-size: 13.5px; + font-weight: 700; + color: $navy; + font-variant-numeric: tabular-nums; + + &--positive { + color: $positive; + } + } + + &--highlight { + background: $accent-soft; + + .rh-stat__value { + color: $accent; + } + } +} + +/* ─── Empty state ────────────────────────────────────────────────────── */ +.rh-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + padding: 64px 16px; + color: $muted; + + &__icon { + font-size: 30px; + opacity: 0.4; + } + + &__text { + margin: 0; + font-size: 13px; + } +} 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 new file mode 100644 index 000000000..34bd1bb2b --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.ts @@ -0,0 +1,235 @@ +/** + * 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, OnInit, inject } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { MatDialog } from '@angular/material/dialog'; +import { Currency } from 'app/shared/models/general.model'; +import { CurrencyPipe } from '@angular/common'; +import { DateFormatPipe } from '../../../../pipes/date-format.pipe'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; +import { ProductsService } from 'app/products/products.service'; +import { TermRateChartDialogComponent } from './term-rate-chart-dialog/term-rate-chart-dialog.component'; + +/** + * Every term of one FD, oldest first, as a vertical timeline -- one card per renewal cycle. Its own tab + * rather than a card on General, since a long-lived FD can carry many terms and each one's full detail + * (dates, rate, principal, interest, and its own rate chart) needs real room to read comfortably. + */ +@Component({ + selector: 'mifosx-renewal-history-tab', + templateUrl: './renewal-history-tab.component.html', + styleUrls: ['./renewal-history-tab.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + FaIconComponent, + CurrencyPipe, + DateFormatPipe + ] +}) +export class RenewalHistoryTabComponent implements OnInit { + private route = inject(ActivatedRoute); + private router = inject(Router); + private dialog = inject(MatDialog); + private productsService = inject(ProductsService); + + fixedDepositsAccountData: any; + currency: Currency; + + /** + * The bank's currently published senior-citizen premium per tenure band, for this FD's type. Fetched once + * from the same CBS Base Interest Rates screen staff maintain, so a term's rate-chart popup can show the + * premium alongside the standard rate -- neither the account's own chart snapshot nor the archived history + * carries a senior-citizen column, since that concept only exists on the base rate master. + */ + private activeBaseSlabs: any[] = []; + + constructor() { + this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { + this.fixedDepositsAccountData = data.fixedDepositsAccountData; + this.currency = this.fixedDepositsAccountData.currency; + }); + } + + ngOnInit(): void { + const fdType = this.fixedDepositsAccountData?.fdType; + if (!fdType) { + return; + } + this.productsService.getFdBaseInterestRates(fdType).subscribe({ + next: (rates: any[]) => { + const active = (rates || []).find((r: any) => r.isActive); + this.activeBaseSlabs = active?.slabs ?? []; + }, + error: () => {} + }); + } + + /** + * The published senior-citizen rate for a tenure band, matched by period type and range against the active + * base rate card. Returns null when nothing matches (no published base version for this FD type, or this + * particular band isn't on it, or the matching band simply carries no premium) -- displayed as a blank + * rather than a misleading zero. + */ + private seniorCitizenRateFor(periodTypeEnum: number, fromPeriod: number, toPeriod: number | null): number | null { + const match = this.activeBaseSlabs.find( + (s) => + s.periodTypeEnum === periodTypeEnum && + s.fromPeriod === fromPeriod && + (s.toPeriod ?? null) === (toPeriod ?? null) + ); + return match?.seniorCitizenRate > 0 ? match.seniorCitizenRate : null; + } + + /** + * A reinvested FD always keeps the same client/group/center as the account it came from, so the + * counterpart account's view page sits at the same route -- only the account-id segment differs. + */ + private siblingAccountLink(targetAccountId: number): string | null { + const segments = this.router.url.split('?')[0].split('/').filter(Boolean); + const idx = segments.indexOf('fixed-deposits-accounts'); + if (idx === -1) return null; + return '/' + segments.slice(0, idx + 1).join('/') + '/' + targetAccountId + '/general'; + } + + siblingLink(targetAccountId: number): string | null { + return this.siblingAccountLink(targetAccountId); + } + + /** + * Every term of this FD, oldest first, as resolved by the backend. Keys are lower-cased because they + * arrive straight from the SQL result-set column labels. + */ + get renewalHistory(): any[] { + return this.fixedDepositsAccountData?.renewalHistory ?? []; + } + + get fdNumber(): string { + return this.fixedDepositsAccountData?.fdAccountNumber ?? this.fixedDepositsAccountData?.accountNo; + } + + /** The very first term's opening principal -- what the customer originally deposited. */ + get originalPrincipal(): number | null { + return this.renewalHistory[0]?.depositamount ?? null; + } + + /** The latest term's settled or projected value -- what the FD is worth right now. */ + get currentValue(): number | null { + const latest = this.renewalHistory[this.renewalHistory.length - 1]; + return latest ? this.maturityFor(latest) : null; + } + + /** + * Total interest earned across every term, settled terms plus the running term's interest-to-date. Null + * only when there is nothing to sum, so the summary strip can tell "no data yet" apart from "zero". + */ + get lifetimeInterestEarned(): number | null { + if (!this.renewalHistory.length) { + return null; + } + let total = 0; + for (const term of this.renewalHistory) { + if (term.interestearned != null) { + total += Number(term.interestearned); + } else { + const projected = this.maturityFor(term); + if (projected != null && term.depositamount != null) { + total += Number(projected) - Number(term.depositamount); + } + } + } + return total; + } + + /** + * A term's settled maturity amount, or its projection while it is still running. Resolved here rather + * than inline so the choice isn't left to operator precedence between `??` and the currency pipe. + */ + maturityFor(term: any): number | null { + return term?.maturedamount ?? term?.projectedmaturityamount ?? null; + } + + /** periodTypeEnum values as archived -- same encoding as SavingsPeriodFrequencyType (DAYS=0 ... YEARS=3). */ + private static readonly PERIOD_TYPE_LABELS: Record = { + 0: 'Days', + 1: 'Weeks', + 2: 'Months', + 3: 'Years' + }; + + /** "10 Years" rather than a bare number, using the same period-type encoding the archived charts use. */ + tenureLabel(term: any): string { + if (term?.tenure == null) { + return ''; + } + const unit = RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[term.tenurefrequencyenum] ?? ''; + return unit ? `${term.tenure} ${unit}` : `${term.tenure}`; + } + + /** How the renewal happened, for display as a small tag -- null when the term hasn't renewed (yet). */ + renewalTypeLabel(term: any): string | null { + if (term?.renewaltype === 'AUTO') return 'Auto-Renewed'; + if (term?.renewaltype === 'MANUAL') return 'Renewed by Staff'; + return null; + } + + renewalTypeIcon(term: any): string { + return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot'; + } + + /** + * The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source. + * + * A term's chart is only archived once it closes via an in-place renewal, so the term still running has no + * archived rows -- for that one term (recognisable as the one matching this account's own id), the + * account's own live chart is shown instead, since it is the same data. The two sources use different key + * casing and represent the period type differently (a raw enum int here, an expanded {value} object on the + * live chart), which this maps into one consistent shape the dialog can render without knowing which source + * it came from. + */ + private chartSlabsFor(term: any): any[] { + const raw = term?.chartSlabs?.length + ? term.chartSlabs + : term?.accountid === this.fixedDepositsAccountData?.id + ? (this.fixedDepositsAccountData?.accountChart?.chartSlabs ?? []) + : []; + return raw.map((slab: any) => { + const periodTypeEnum = slab.periodType?.id ?? slab.periodtypeenum; + const fromPeriod = slab.fromperiod ?? slab.fromPeriod; + const toPeriod = slab.toperiod ?? slab.toPeriod; + // The archived/live row's own senior_citizen_rate is the true figure as published when this term + // was actually priced -- preferred whenever present. The live lookup against today's base rate + // card is only a fallback, for the currently-running term (not archived yet) or an older archive + // row written before that column existed -- flagged as such so the dialog can say so. + const persistedSeniorRate = slab.seniorcitizenrate ?? slab.seniorCitizenRate ?? null; + return { + description: slab.description, + fromPeriod, + toPeriod, + periodTypeLabel: slab.periodType?.value ?? RenewalHistoryTabComponent.PERIOD_TYPE_LABELS[periodTypeEnum] ?? '', + annualInterestRate: slab.annualinterestrate ?? slab.annualInterestRate, + seniorCitizenRate: + persistedSeniorRate ?? this.seniorCitizenRateFor(periodTypeEnum, fromPeriod, toPeriod ?? null), + seniorCitizenRateIsLive: persistedSeniorRate == null + }; + }); + } + + /** Opens the full rate chart for one term in a popup, rather than pushing every later term down the page. */ + openRateChart(term: any): void { + this.dialog.open(TermRateChartDialogComponent, { + width: '560px', + data: { + term, + slabs: this.chartSlabsFor(term), + isSeniorCitizen: !!this.fixedDepositsAccountData?.isSeniorCitizen + } + }); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html new file mode 100644 index 000000000..7eadb1179 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.html @@ -0,0 +1,77 @@ + + +
+

+ + + {{ termLabel }} — Rate Chart + {{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }} + +

+ +
+ @if (slabs.length) { +
{{ 'labels.inputs.Account No' | translate }} - + + @if (element.renewalCycleNo) { + Renewal {{ element.renewalCycleNo }} + } {{ chartSlab.annualInterestRate | formatNumber }} % Senior Citizen + {{ + chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined + ? (chartSlab.seniorCitizenRate | formatNumber) + ' %' + : '-' + }} + {{ 'labels.inputs.Description' | translate }} {{ chartSlab.description }}
+ + + + + + + + + + @for (slab of slabs; track $index) { + + + + + + + } + +
DescriptionTenureRate + + Senior Citizen +
{{ slab.description }}{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }} + {{ slab.annualInterestRate }}% + @if (isAppliedSlab(slab)) { + Applied + } + + {{ + slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined + ? slab.seniorCitizenRate + '%' + : '—' + }} + @if (isAppliedSeniorSlab(slab)) { + Applied + } +
+ @if (hasLiveSeniorRate) { +

+ + Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for + reference. +

+ } + } @else { +
+ +

No rate chart was archived for this term.

+
+ } +
+ +
+ +
+ diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss new file mode 100644 index 000000000..a0f4ec851 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.scss @@ -0,0 +1,159 @@ +/** + * 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/. + */ + +$navy: #0d1b2a; +$slate: #475569; +$muted: #64748b; +$border: #e2e8f0; +$soft: #f8fafc; +$positive: #047857; +$positive-soft: #ecfdf5; + +$accent: #b8860b; + +.trc-dialog { + min-width: 420px; + max-width: 540px; +} + +.trc-header { + display: flex; + align-items: center; + gap: 12px; + margin: 0 0 4px; + + &__icon { + flex: none; + width: 38px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 10px; + background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%); + color: #fff; + font-size: 15px; + } + + &__text { + display: flex; + flex-direction: column; + min-width: 0; + } + + &__title { + font-size: 15px; + font-weight: 700; + color: $navy; + line-height: 1.3; + } + + &__sub { + font-size: 12px; + color: $muted; + font-weight: 500; + } +} + +.trc-content { + padding-top: 4px; +} + +.trc-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + padding: 8px 10px; + background: $soft; + color: $muted; + font-weight: 600; + text-align: left; + border-bottom: 1px solid $border; + } + + td { + padding: 9px 10px; + color: $slate; + border-bottom: 1px solid $border; + font-variant-numeric: tabular-nums; + } + + &__rate-col { + text-align: right; + } + + &__row--applied { + background: $positive-soft; + + td { + color: $positive; + font-weight: 700; + } + } + + &__sc-ico { + color: $accent; + margin-right: 4px; + } + + &__sc-col { + color: $accent; + font-weight: 600; + } +} + +.trc-applied-badge { + display: inline-block; + margin-left: 6px; + padding: 1px 7px; + border-radius: 10px; + background: $positive; + color: #fff; + font-size: 9.5px; + font-weight: 700; + letter-spacing: 0.03em; + text-transform: uppercase; + vertical-align: middle; +} + +.trc-note { + display: flex; + align-items: flex-start; + gap: 6px; + margin: 10px 2px 0; + color: $muted; + font-size: 11px; + font-style: italic; + line-height: 1.4; +} + +.trc-empty { + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; + padding: 32px 8px; + color: $muted; + text-align: center; + + &__icon { + font-size: 22px; + opacity: 0.5; + } + + p { + margin: 0; + font-size: 13px; + } +} + +.trc-actions { + padding-top: 6px; +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts new file mode 100644 index 000000000..3ce7957f9 --- /dev/null +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/term-rate-chart-dialog/term-rate-chart-dialog.component.ts @@ -0,0 +1,93 @@ +/** + * 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, inject } from '@angular/core'; +import { + MAT_DIALOG_DATA, + MatDialogRef, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose +} from '@angular/material/dialog'; +import { MatButton } from '@angular/material/button'; +import { FaIconComponent } from '@fortawesome/angular-fontawesome'; +import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; + +/** + * The full rate-by-tenure chart one term of an FD was priced on, in a popup rather than inline in the + * timeline: a chart can run to several tenure bands, and showing it in place would push every later term + * down the page every time one is opened. + */ +@Component({ + selector: 'mifosx-term-rate-chart-dialog', + templateUrl: './term-rate-chart-dialog.component.html', + styleUrls: ['./term-rate-chart-dialog.component.scss'], + imports: [ + ...STANDALONE_SHARED_IMPORTS, + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + MatButton, + FaIconComponent + ] +}) +export class TermRateChartDialogComponent { + dialogRef = inject>(MatDialogRef); + data = inject(MAT_DIALOG_DATA); + + get term(): any { + return this.data.term; + } + + get slabs(): any[] { + return this.data.slabs ?? []; + } + + get termLabel(): string { + return this.term?.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + this.term?.renewalcycleno; + } + + /** Whether the DEPOSITOR (not just the tenure band) was a senior citizen for this term. */ + get isSeniorCitizen(): boolean { + return !!this.data.isSeniorCitizen; + } + + /** + * True when at least one senior-citizen figure shown is a live lookup against today's base rate card rather than the + * true rate persisted for this specific term -- only possible for the currently-running term (not archived yet) or an + * older archive row that predates the persisted column. Drives whether the disclaimer note is shown at all. + */ + get hasLiveSeniorRate(): boolean { + return this.slabs.some((s) => s.seniorCitizenRateIsLive && s.seniorCitizenRate != null); + } + + /** + * True for the STANDARD-rate cell that was actually applied to this term. Never true for a senior-citizen + * term, since that term was priced off the senior column instead -- see {@link isAppliedSeniorSlab}. + * Compared as numbers since one side arrives as a string from the currency-agnostic archive row. + */ + isAppliedSlab(slab: any): boolean { + return ( + !this.isSeniorCitizen && + this.term?.interestrate != null && + Number(slab.annualInterestRate) === Number(this.term.interestrate) + ); + } + + /** True for the SENIOR-CITIZEN cell that was actually applied, for a term held by a senior-citizen depositor. */ + isAppliedSeniorSlab(slab: any): boolean { + return ( + this.isSeniorCitizen && + slab.seniorCitizenRate != null && + this.term?.interestrate != null && + Number(slab.seniorCitizenRate) === Number(this.term.interestrate) + ); + } +} diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html index ae4dafdf5..6e447a59c 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.html @@ -36,7 +36,11 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.id }} + @if (transaction.isRenewalEvent) { + + } @else { + {{ transaction.id }} + } @@ -72,7 +76,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -84,7 +94,13 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ !isDebit(transaction.transactionType) ? (transaction.amount | formatNumber) : 'N/A' }} + {{ + transaction.isRenewalEvent + ? '—' + : !isDebit(transaction.transactionType) + ? (transaction.amount | formatNumber) + : 'N/A' + }} @@ -96,36 +112,40 @@ [ngClass]="savingsTransactionColor(transaction)" (click)="showTransactions(transaction)" > - {{ transaction.runningBalance | formatNumber }} + {{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }} {{ 'labels.inputs.Actions' | translate }} - - - - @if (!isFirstTransaction(idx)) { - - } - - + @if (!isFirstTransaction(idx)) { + + } + + + } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss index 44062e84c..57358a351 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.scss @@ -97,4 +97,32 @@ table { color: $muted; font-style: italic; } + + .mat-mdc-cell.renewal-marker { + background: #eff6ff; + color: $blue; + font-style: italic; + cursor: default; + } + + .mat-mdc-row:has(.renewal-marker):hover { + background: #eff6ff; + cursor: default; + } +} + +.txn-tab__renewal-ico { + color: $blue; +} + +.txn-tab__renewal-tag { + display: inline-block; + padding: 2px 8px; + border-radius: 10px; + background: #dbeafe; + color: $blue; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; } diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts index 5caa3e47e..07691eaa6 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/transactions-tab/transactions-tab.component.ts @@ -113,13 +113,71 @@ export class TransactionsTabComponent implements OnInit { */ constructor() { this.route.parent.data.subscribe((data: { fixedDepositsAccountData: any }) => { - this.transactionsData = data.fixedDepositsAccountData.transactions; + this.transactionsData = this.withRenewalMarkers( + data.fixedDepositsAccountData.transactions, + data.fixedDepositsAccountData.renewalHistory + ); this.clientId = this.route.parent.snapshot.params['clientId']; this.accountId = this.route.parent.snapshot.params['fixedDepositAccountId']; this.status = data.fixedDepositsAccountData.status.value; }); } + /** + * An in-place renewal moves no money -- the matured balance simply becomes the new term's opening + * principal -- so it posts no withdrawal, deposit, or journal entry (see + * DepositAccountDomainServiceJpa#handleFDAccountMaturityClosure). Without something here, a renewal + * would be invisible in this list even though the account's rate and term just changed. These rows + * are synthesized purely for display from the renewal history already loaded for the Renewal History + * tab -- never written anywhere, never matched against a real transaction id -- so undo, journal-entry + * lookup, and every real transaction's running balance stay exactly as they were. + */ + private withRenewalMarkers(transactions: any[], renewalHistory: any[]): any[] { + const markers: any[] = (renewalHistory ?? []) + .filter((term: any) => term.renewalcycleno > 0) + .map((term: any) => { + const marker: any = { + isRenewalEvent: true, + id: null, + date: term.activatedondate, + amount: null, + runningBalance: null, + reversed: false, + transfer: null, + renewalCycleNo: term.renewalcycleno, + transactionType: { + value: term.renewaltype === 'MANUAL' ? 'FD Renewed (Staff)' : 'FD Auto-Renewed', + accrual: false, + withdrawal: false, + feeDeduction: false, + overdraftInterest: false, + withholdTax: false + } + }; + return marker; + }); + return [ + ...(transactions ?? []), + ...markers + ].sort((a, b) => this.toEpoch(b.date) - this.toEpoch(a.date)); + } + + /** Same [yyyy, MM, dd]-or-ISO-string shape DateFormatPipe already handles, resolved to a sortable epoch. */ + private toEpoch(value: any): number { + if (!value) { + return 0; + } + if (Array.isArray(value)) { + const [ + year, + month, + day + ] = value; + return new Date(year, (month ?? 1) - 1, day ?? 1).getTime(); + } + return new Date(value).getTime(); + } + ngOnInit() { this.hideAccrualsParam = new UntypedFormControl(false); this.hideReversedParam = new UntypedFormControl(false); @@ -161,6 +219,9 @@ export class TransactionsTabComponent implements OnInit { * @param {any} transactionsData Transactions Data */ showTransactions(transactionsData: any) { + if (transactionsData.isRenewalEvent) { + return; + } if (transactionsData.transfer) { this.router.navigate([`account-transfers/account-transfers/${transactionsData.transfer.id}`], { relativeTo: this.route @@ -191,7 +252,10 @@ export class TransactionsTabComponent implements OnInit { this.dataSource.sort = this.sort; } - savingsTransactionColor(transaction: SavingsAccountTransaction): string { + savingsTransactionColor(transaction: any): string { + if (transaction.isRenewalEvent) { + return 'renewal-marker'; + } if (transaction.reversed) { return 'strike'; } else if (transaction.transfer) { diff --git a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts index 68861fff9..eb2f6e5c5 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits-routing.module.ts @@ -31,6 +31,7 @@ import { FixedDepositsAccountActionsResolver } from './common-resolvers/fixed-de import { FixedDepositsAccountTemplateResolver } from './common-resolvers/fixed-deposit-account-template.resolver'; import { FixedDepositsAccountAndTemplateResolver } from './common-resolvers/fixed-deposit-account-and-template.resolver'; import { GeneralTabComponent } from './fixed-deposit-account-view/general-tab/general-tab.component'; +import { RenewalHistoryTabComponent } from './fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component'; import { AccountNomineesTabComponent } from 'app/shared/account-nominees-tab/account-nominees-tab.component'; const routes: Routes = [ @@ -80,6 +81,15 @@ const routes: Routes = [ routeParamBreadcrumb: false } }, + { + path: 'renewal-history', + component: RenewalHistoryTabComponent, + data: { + title: 'Fixed Deposit Account Renewal History', + breadcrumb: 'Renewal History', + routeParamBreadcrumb: false + } + }, { path: 'transactions', component: TransactionsTabComponent, diff --git a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts index 6d20ed276..dd82ede92 100644 --- a/src/app/deposits/fixed-deposits/fixed-deposits.service.ts +++ b/src/app/deposits/fixed-deposits/fixed-deposits.service.ts @@ -87,6 +87,17 @@ export class FixedDepositsService { return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); } + /** + * Template for the Payment action. Same shape as the closure template, but its `onAccountClosureOptions` also + * include Partial Re-Invest, which the closure commands exclude because only Payment implements it. + * @param {string} accountId Fixed Deposits Account Id + * @returns {Observable} + */ + getFixedDepositsAccountPaymentTemplate(accountId: string): Observable { + const httpParams = new HttpParams().set('command', 'payment'); + return this.http.get(`/fixeddepositaccounts/${accountId}/template`, { params: httpParams }); + } + /** * @param {string} accountId Fixed Deposits Account Id * @returns {Observable} diff --git a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html index e2862f74f..f5d32347b 100644 --- a/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html +++ b/src/app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component.html @@ -36,7 +36,7 @@
Account No. - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }}
Date of Issue @@ -49,9 +49,13 @@ This is to certify that {{ accountData.clientName || accountData.groupName }} holds a {{ accountType === 'FD' ? 'Fixed Deposit' : 'Recurring Deposit' }} account bearing account number - {{ accountData.accountNo }} + {{ accountData.fdAccountNumber || accountData.accountNo }} with Nexora Co-operative Bank Ltd., as per the particulars set out below. + + This account was renewed on maturity {{ renewalCount }} {{ renewalCount === 1 ? 'time' : 'times' }}; the + full renewal history is set out below. +

@@ -129,6 +133,44 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
RENEWAL HISTORY
TermFromToRatePrincipalInterestMaturity Amount
{{ term.renewalcycleno === 0 ? 'Original' : 'Renewal ' + term.renewalcycleno }}{{ term.activatedondate | dateFormat }}{{ term.maturitydate | dateFormat }}{{ term.interestrate }}%{{ term.depositamount | currency: accountData.currency.code : 'symbol-narrow' }} + {{ + term.interestearned === null || term.interestearned === undefined + ? '—' + : (term.interestearned | currency: accountData.currency.code : 'symbol-narrow') + }} + {{ maturityFor(term) | currency: accountData.currency.code : 'symbol-narrow' }}
+