feat(deposits): RBI premature closure compliance UI, renewal-history status fix
Premature Close dialog: adds the Closure Reason dropdown, the explicit Waive Penalty checkbox, a tenure/date preview panel, and an "Effective Rate for this closure" display so the maker sees exactly what rate and penalty treatment is about to apply. Renewal History tab: the latest term's card kept showing an ACTIVE badge even after the account was prematurely closed, since the archived renewal-status column only ever updates on an actual renewal rollover. For the last card only, status now cross-checks the account's own live status instead. "Projected Maturity" now reads "Closed At" once that term has prematurely closed, rather than implying it's still a projection. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+78
-2
@@ -51,12 +51,53 @@
|
|||||||
}
|
}
|
||||||
</mat-form-field>
|
</mat-form-field>
|
||||||
|
|
||||||
|
<mat-form-field appearance="outline">
|
||||||
|
<mat-label>Closure Reason</mat-label>
|
||||||
|
<mat-select formControlName="prematureClosureReasonId">
|
||||||
|
@for (reason of closureReasonOptions; track reason.id) {
|
||||||
|
<mat-option [value]="reason.id">{{ reason.label }}</mat-option>
|
||||||
|
}
|
||||||
|
</mat-select>
|
||||||
|
</mat-form-field>
|
||||||
|
|
||||||
@if (loadingAmount) {
|
@if (loadingAmount) {
|
||||||
<div class="pcd__loading">
|
<div class="pcd__loading">
|
||||||
<mat-spinner diameter="28"></mat-spinner>
|
<mat-spinner diameter="28"></mat-spinner>
|
||||||
<span>Calculating premature-closure amount...</span>
|
<span>Calculating premature-closure amount...</span>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
|
@if (data.activatedOnDate || data.maturityDate || data.currentBalance !== undefined) {
|
||||||
|
<div class="pcd__preview pcd__preview--tenure">
|
||||||
|
@if (data.activatedOnDate) {
|
||||||
|
<div class="pcd__preview-row">
|
||||||
|
<span class="pcd__preview-label">Current Term Started</span>
|
||||||
|
<span class="pcd__preview-value">{{ activatedOnDateDisplay }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="pcd__preview-row">
|
||||||
|
<span class="pcd__preview-label">Tenure Completed (this term)</span>
|
||||||
|
<span class="pcd__preview-value"
|
||||||
|
>{{ tenureElapsedDisplay }} <span class="pcd__preview-sub">({{ daysElapsed }} days)</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.maturityDate) {
|
||||||
|
<div class="pcd__preview-row">
|
||||||
|
<span class="pcd__preview-label">Scheduled Maturity Date</span>
|
||||||
|
<span class="pcd__preview-value"
|
||||||
|
>{{ maturityDateDisplay }}
|
||||||
|
<span class="pcd__preview-sub">({{ daysRemainingToMaturity }} days left)</span></span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (data.currentBalance !== undefined && data.currentBalance !== null) {
|
||||||
|
<div class="pcd__preview-row">
|
||||||
|
<span class="pcd__preview-label">Current Balance (as of today)</span>
|
||||||
|
<span class="pcd__preview-value">{{ data.currentBalance | formatNumber }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (maturityAmount !== null && maturityAmount !== undefined) {
|
@if (maturityAmount !== null && maturityAmount !== undefined) {
|
||||||
<div class="pcd__preview">
|
<div class="pcd__preview">
|
||||||
@if (data.depositAmount) {
|
@if (data.depositAmount) {
|
||||||
@@ -71,12 +112,27 @@
|
|||||||
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
|
<span class="pcd__preview-value">{{ data.nominalAnnualInterestRate }}%</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@if (effectiveInterestRate !== null && effectiveInterestRate !== undefined) {
|
||||||
|
<div class="pcd__preview-row pcd__preview-row--highlight">
|
||||||
|
<span class="pcd__preview-label">Effective Rate for this closure</span>
|
||||||
|
<span class="pcd__preview-value">{{ effectiveInterestRate }}%</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@if (data.preClosurePenalApplicable) {
|
@if (data.preClosurePenalApplicable) {
|
||||||
<div class="pcd__preview-row">
|
<div
|
||||||
|
class="pcd__preview-row"
|
||||||
|
[class.pcd__preview-row--waived]="prematureCloseForm.get('waivePreClosurePenalty').value"
|
||||||
|
>
|
||||||
<span class="pcd__preview-label"
|
<span class="pcd__preview-label"
|
||||||
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
|
>Pre-Closure Penalty (on {{ data.preClosurePenalInterestOnType?.value }})</span
|
||||||
>
|
>
|
||||||
<span class="pcd__preview-value">− {{ data.preClosurePenalInterest }}%</span>
|
<span class="pcd__preview-value">
|
||||||
|
@if (prematureCloseForm.get('waivePreClosurePenalty').value) {
|
||||||
|
<span class="pcd__penalty-waived-tag">Waived</span>
|
||||||
|
} @else {
|
||||||
|
− {{ data.preClosurePenalInterest }}%
|
||||||
|
}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (interestEarned !== null) {
|
@if (interestEarned !== null) {
|
||||||
@@ -94,6 +150,26 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@if (data.preClosurePenalApplicable) {
|
||||||
|
@if (isAutoWaivedReason) {
|
||||||
|
<div class="pcd__waive-locked">
|
||||||
|
<fa-icon icon="check-circle" class="m-r-10"></fa-icon>
|
||||||
|
<span>
|
||||||
|
Penalty automatically waived -- <strong>{{ selectedReasonLabel }}</strong> always waives the
|
||||||
|
pre-closure penalty, regardless of this checkbox.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<mat-checkbox formControlName="waivePreClosurePenalty" class="pcd__waive-checkbox">
|
||||||
|
Waive the pre-closure penalty for this closure
|
||||||
|
<span class="pcd__waive-hint">
|
||||||
|
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.
|
||||||
|
</span>
|
||||||
|
</mat-checkbox>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
<div class="pcd__section-label" id="pcd-action-label">{{ 'labels.inputs.Action' | translate }}</div>
|
||||||
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
|
<div class="pcd__options" role="radiogroup" aria-labelledby="pcd-action-label">
|
||||||
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
@for (closureOption of onAccountClosureOptions; track closureOption) {
|
||||||
|
|||||||
+80
@@ -204,6 +204,86 @@
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #0f172a;
|
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 {
|
.pcd__section-label {
|
||||||
|
|||||||
+174
-2
@@ -67,6 +67,29 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
[PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account'
|
[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<PrematureCloseDialogComponent>>(MatDialogRef);
|
dialogRef = inject<MatDialogRef<PrematureCloseDialogComponent>>(MatDialogRef);
|
||||||
data: {
|
data: {
|
||||||
accountId: number | string;
|
accountId: number | string;
|
||||||
@@ -76,6 +99,11 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
preClosurePenalApplicable?: boolean;
|
preClosurePenalApplicable?: boolean;
|
||||||
preClosurePenalInterest?: number;
|
preClosurePenalInterest?: number;
|
||||||
preClosurePenalInterestOnType?: { value: string };
|
preClosurePenalInterestOnType?: { value: string };
|
||||||
|
deceasedSettlement?: boolean;
|
||||||
|
noPenaltyNomineeHeirClosure?: boolean;
|
||||||
|
activatedOnDate?: any;
|
||||||
|
maturityDate?: any;
|
||||||
|
currentBalance?: number;
|
||||||
} = inject(MAT_DIALOG_DATA);
|
} = inject(MAT_DIALOG_DATA);
|
||||||
private formBuilder = inject(UntypedFormBuilder);
|
private formBuilder = inject(UntypedFormBuilder);
|
||||||
private fixedDepositsService = inject(FixedDepositsService);
|
private fixedDepositsService = inject(FixedDepositsService);
|
||||||
@@ -86,6 +114,10 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
onAccountClosureOptions: any[] = [];
|
onAccountClosureOptions: any[] = [];
|
||||||
savingsAccountsData: any[] = [];
|
savingsAccountsData: any[] = [];
|
||||||
maturityAmount: number = null;
|
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;
|
loadingAmount = true;
|
||||||
loadFailed = false;
|
loadFailed = false;
|
||||||
submitting = false;
|
submitting = false;
|
||||||
@@ -99,17 +131,66 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
closedOnDate: [
|
closedOnDate: [
|
||||||
this.maxDate,
|
this.maxDate,
|
||||||
Validators.required
|
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
|
this.prematureCloseForm
|
||||||
.get('closedOnDate')
|
.get('closedOnDate')
|
||||||
.valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
|
.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
|
// 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.
|
// maker happens to touch the date picker, which the full-page form this replaces required too.
|
||||||
this.calculatePrematureAmount(this.maxDate);
|
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 {
|
private calculatePrematureAmount(date: Date): void {
|
||||||
this.loadingAmount = true;
|
this.loadingAmount = true;
|
||||||
this.loadFailed = false;
|
this.loadFailed = false;
|
||||||
@@ -118,7 +199,9 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
const data = {
|
const data = {
|
||||||
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
closedOnDate: this.dateUtils.formatDate(date, dateFormat),
|
||||||
dateFormat,
|
dateFormat,
|
||||||
locale
|
locale,
|
||||||
|
prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
|
||||||
|
waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
|
||||||
};
|
};
|
||||||
this.fixedDepositsService
|
this.fixedDepositsService
|
||||||
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
|
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
|
||||||
@@ -127,6 +210,7 @@ export class PrematureCloseDialogComponent implements OnInit {
|
|||||||
this.savingsAccountsData = response.savingsAccounts ?? [];
|
this.savingsAccountsData = response.savingsAccounts ?? [];
|
||||||
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
|
this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
|
||||||
this.maturityAmount = response.maturityAmount;
|
this.maturityAmount = response.maturityAmount;
|
||||||
|
this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
|
||||||
this.loadingAmount = false;
|
this.loadingAmount = false;
|
||||||
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
|
if (!this.prematureCloseForm.contains('onAccountClosureId')) {
|
||||||
this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
|
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);
|
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. */
|
/** True when the chosen action needs a destination account but the customer has none to pick. */
|
||||||
get noDestinationAccounts(): boolean {
|
get noDestinationAccounts(): boolean {
|
||||||
return (
|
return (
|
||||||
|
|||||||
+27
-1
@@ -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
|
* Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
|
||||||
* matured yet.
|
* matured yet.
|
||||||
@@ -316,7 +328,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
|
|||||||
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
|
nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
|
||||||
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
|
preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
|
||||||
preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
|
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) => {
|
prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
|
||||||
|
|||||||
+8
-10
@@ -41,8 +41,8 @@
|
|||||||
) {
|
) {
|
||||||
<div class="rh-node">
|
<div class="rh-node">
|
||||||
<div class="rh-node__rail">
|
<div class="rh-node__rail">
|
||||||
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
|
<span class="rh-node__dot" [class.rh-node__dot--active]="isTermActive(term, last)">
|
||||||
@if (term.renewalstatus === 'ACTIVE') {
|
@if (isTermActive(term, last)) {
|
||||||
<span class="rh-node__pulse"></span>
|
<span class="rh-node__pulse"></span>
|
||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,15 +51,15 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
|
<div class="rh-card" [class.rh-card--active]="isTermActive(term, last)">
|
||||||
<div class="rh-card__head">
|
<div class="rh-card__head">
|
||||||
<div class="rh-card__title-group">
|
<div class="rh-card__title-group">
|
||||||
<h4 class="rh-card__title">
|
<h4 class="rh-card__title">
|
||||||
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
|
||||||
</h4>
|
</h4>
|
||||||
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
|
<span class="rh-badge" [class.rh-badge--active]="isTermActive(term, last)">
|
||||||
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
|
<fa-icon [icon]="isTermActive(term, last) ? 'check-circle' : 'clock'"></fa-icon>
|
||||||
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
|
{{ termStatusLabel(term, last) }}
|
||||||
</span>
|
</span>
|
||||||
@if (renewalTypeLabel(term)) {
|
@if (renewalTypeLabel(term)) {
|
||||||
<span class="rh-tag">
|
<span class="rh-tag">
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="rh-stat">
|
<div class="rh-stat">
|
||||||
<span class="rh-stat__label">{{
|
<span class="rh-stat__label">{{
|
||||||
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
|
isTermActive(term, last) ? 'Interest So Far' : 'Interest Earned'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="rh-stat__value rh-stat__value--positive">
|
<span class="rh-stat__value rh-stat__value--positive">
|
||||||
{{
|
{{
|
||||||
@@ -106,9 +106,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="rh-stat rh-stat--highlight">
|
<div class="rh-stat rh-stat--highlight">
|
||||||
<span class="rh-stat__label">{{
|
<span class="rh-stat__label">{{ maturityLabel(term, last) }}</span>
|
||||||
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
|
|
||||||
}}</span>
|
|
||||||
<span class="rh-stat__value">
|
<span class="rh-stat__value">
|
||||||
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
+45
@@ -183,6 +183,51 @@ export class RenewalHistoryTabComponent implements OnInit {
|
|||||||
return term?.renewaltype === 'MANUAL' ? 'user-cog' : 'robot';
|
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.
|
* The rate-by-tenure chart for one term of this FD's history, normalised to one shape regardless of source.
|
||||||
*
|
*
|
||||||
|
|||||||
Reference in New Issue
Block a user