Merge remote-tracking branch 'origin/dev-latest' into feature/dds-agent-settlement-reports

# Conflicts:
#	src/app/core/shell/shell.component.html
#	src/app/organization/organization-routing.module.ts
#	src/environments/.env.ts
This commit is contained in:
tarunmalviya
2026-09-23 12:11:12 +05:30
605 changed files with 74890 additions and 8182 deletions
@@ -33,6 +33,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -107,7 +108,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -121,6 +155,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -24,6 +24,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -56,6 +58,10 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Fixed Deposits Account Template */
fixedDepositsAccountTemplate: any;
@@ -63,8 +69,8 @@ export class CreateFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
/** Total wizard tabs — Product, Currency, Chart, Terms, Preview. */
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -243,6 +249,7 @@ export class CreateFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -35,6 +35,7 @@
<mifosx-fixed-deposit-account-details-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
(fixedDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="fixedDepositAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
[hideButtons]="true"
>
</mifosx-fixed-deposit-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cfa-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cfa-tab-body">
@if (fixedDepositsAccountProductTemplate) {
<mifosx-fixed-deposit-account-maturity-terms-step
[fixedDepositsAccountTemplate]="fixedDepositsAccountAndTemplate"
[fixedDepositsAccountProductTemplate]="fixedDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? fixedDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-fixed-deposit-account-maturity-terms-step>
}
<div class="cfa-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cfa-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!fixedDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cfa-tab__icon"></fa-icon>
@@ -125,6 +159,7 @@
[fixedDepositAccountData]="fixedDepositAccount"
[selectedTenure]="selectedTenure"
[fdNominees]="fixedDepositAccountCurrencyStep?.nominees ?? []"
[closureSettings]="fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
></mifosx-fixed-deposit-account-preview-step>
@@ -25,6 +25,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { FixedDepositAccountInterestRateChartStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component';
import { FixedDepositAccountPreviewStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-preview-step/fixed-deposit-account-preview-step.component';
import { FixedDepositAccountMaturityTermsStepComponent } from '../fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
FixedDepositAccountDetailsStepComponent,
FixedDepositAccountInterestRateChartStepComponent,
FixedDepositAccountCurrencyStepComponent,
FixedDepositAccountMaturityTermsStepComponent,
FixedDepositAccountPreviewStepComponent
]
})
@@ -58,6 +60,10 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountDetailsStep: FixedDepositAccountDetailsStepComponent;
@ViewChild(FixedDepositAccountCurrencyStepComponent)
fixedDepositAccountCurrencyStep: FixedDepositAccountCurrencyStepComponent;
@ViewChild(FixedDepositAccountPreviewStepComponent)
fixedDepositAccountPreviewStep: FixedDepositAccountPreviewStepComponent;
@ViewChild(FixedDepositAccountMaturityTermsStepComponent)
fixedDepositAccountMaturityTermsStep: FixedDepositAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
fixedDepositsAccountAndTemplate: any;
@@ -65,7 +71,7 @@ export class EditFixedDepositAccountComponent {
fixedDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions for the selected product's FD type */
cbsBaseRates: any[] = [];
/** Tenure selected from the interest rate chart step */
@@ -236,6 +242,7 @@ export class EditFixedDepositAccountComponent {
...this.fixedDepositAccount,
...terms,
...settings,
...(this.fixedDepositAccountMaturityTermsStep?.closureSettings ?? {}),
...(this.selectedTenure
? {
depositPeriod: this.selectedTenure.depositPeriod,
@@ -618,7 +618,7 @@
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Age</mat-label>
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
<mat-hint>Must be below 18 years</mat-hint>
<mat-hint>Must be below {{ minorAgeThreshold }} years</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -16,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { FixedDepositsService } from '../../fixed-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import {
AGE_OF_MAJORITY,
MINOR_AGE_THRESHOLD,
isMinorAge,
isSeniorCitizenAge
} from 'app/clients/shared/client-age.util';
import { MatTooltip } from '@angular/material/tooltip';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -43,7 +59,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class FixedDepositAccountDetailsStepComponent implements OnInit {
export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +72,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
/** Fixed Deposits Account Template */
@Input() fixedDepositsAccountTemplate: any;
@Input() hideButtons = false;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
@@ -94,8 +113,16 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
selectedCustomerSegment: string | null = null;
// ── Client info (auto-fetched) ─────────────────────────────────────────
/**
* The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer
* Segment is derived from this rather than picked by hand, so an account can never disagree with the
* CIF about what kind of customer it belongs to.
*/
clientLegalForm: string | null = null;
clientDob: Date | null = null;
clientAge: number | null = null;
/** Exposed for tooltips so the displayed rule can never drift from the applied rule. */
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
clientGender: string | null = null;
primaryClientName = '';
primaryClientCif = '';
@@ -222,6 +249,23 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.fixedDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -249,7 +293,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
currencyCode: a.currency?.code ?? ''
}));
this.dalLoading = false;
// Pre-select linked account in edit mode
// Pre-select linked account in edit mode. The GET response nests the Fineract association as
// linkedAccount.id, and separately carries the CBS column as linkedAccountId -- the latter is the
// fallback for accounts that have the CBS field set but no association row.
const linkedId =
this.fixedDepositsAccountTemplate?.linkedAccount?.id ?? this.fixedDepositsAccountTemplate?.linkedAccountId;
if (linkedId) {
@@ -329,6 +375,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
this.autoDetectAccountStatus();
}
}
this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null;
this.applyInferredCustomerSegment();
this.cdr.detectChanges();
});
}
@@ -356,8 +404,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
}
isTileDisabled(value: string): boolean {
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
if (value === 'MINOR') return !isMinorAge(this.clientAge);
if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge);
return false;
}
@@ -382,10 +430,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
}
getTileTooltip(value: string): string {
if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) {
return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`;
if (value === 'MINOR' && !isMinorAge(this.clientAge)) {
return `Age ${this.clientAge} yrs — Minor only allowed below years`;
}
if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) {
if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) {
return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`;
}
if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age';
@@ -398,10 +446,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
return;
}
if (this.clientAge === null) return;
if (this.clientAge < 18) {
if (isMinorAge(this.clientAge)) {
this.selectedAccountStatus = 'MINOR';
this.minorDob = this.clientDob;
} else if (this.clientAge >= 60) {
} else if (isSeniorCitizenAge(this.clientAge)) {
this.selectedAccountStatus = 'SENIOR_CITIZEN';
} else if (this.clientGender === 'female') {
this.selectedAccountStatus = 'INDIVIDUAL_FEMALE';
@@ -451,6 +499,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
this.selectedCustomerSegment = response.customerSegment ?? null;
// The product default above is only a fallback -- the client's own legal form wins. Re-applied
// here as well as in the client callback because the two responses can arrive in either order.
this.applyInferredCustomerSegment();
this.fixedDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true;
@@ -561,6 +612,24 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
return fallback ? [fallback] : [];
}
/**
* Selects the Customer Segment that matches the client's legal form. The segment code values
* ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this
* is an exact, case-insensitive comparison on purpose -- a substring match would resolve
* "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product
* default untouched when the client's form is unknown or has no matching option.
*/
private applyInferredCustomerSegment(): void {
if (!this.clientLegalForm || !this.customerSegmentOptions.length) {
return;
}
const target = this.clientLegalForm.trim().toLowerCase();
const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target);
if (match) {
this.selectedCustomerSegment = match;
}
}
formatTemplateDate(value: any): string {
if (!value) return '';
let dateValue: Date | null = null;
@@ -703,7 +772,7 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
const dob = this.minorDob ?? this.clientDob;
if (!dob) return '';
const d = new Date(dob);
d.setFullYear(d.getFullYear() + 18);
d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY);
return this.dateUtils.formatDate(d, 'M/d/yyyy') || '';
}
@@ -767,6 +836,15 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit {
}
if (this.selectedLinkedAccount) {
// The same picked account has to be sent under BOTH names -- they are two different backend
// parameters feeding two different things, not a duplicate:
// linkAccountId (DepositsApiConstants.linkedAccountParamName) -> creates the Fineract
// AccountAssociations row that the monthly interest-transfer job reads, and that
// FD Special's linked-account requirement validates against.
// linkedAccountId (SavingsApiConstants.linkedAccountIdParamName) -> populates the CBS-specific
// m_savings_account.linked_account_id column via applyCbsFields.
// Only linkedAccountId was being sent before, so the association was never created.
value.linkAccountId = this.selectedLinkedAccount.id;
value.linkedAccountId = this.selectedLinkedAccount.id;
value.linkedAccountType = this.selectedLinkedAccount.accountType;
value.linkedAccountNo = this.selectedLinkedAccount.accountNo;
@@ -170,12 +170,12 @@
<!-- Senior Citizen -->
<div class="fdrc-calc-field fdrc-calc-field--senior">
<label class="fdrc-senior-label">
<input type="checkbox" [(ngModel)]="isSeniorCitizen" (change)="onSeniorCitizenChange()" />
<input type="checkbox" [ngModel]="isSeniorCitizen" [disabled]="true" />
<span class="fdrc-senior-text">
<i class="fas fa-user-shield"></i>
Senior Citizen
</span>
<span class="fdrc-senior-hint">Higher rate applied if available</span>
<span class="fdrc-senior-hint">{{ seniorCitizenHint }}</span>
</label>
</div>
@@ -6,7 +6,17 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, Output, EventEmitter, ViewChild, inject } from '@angular/core';
import {
Component,
Input,
OnChanges,
SimpleChanges,
Output,
EventEmitter,
ViewChild,
ChangeDetectorRef,
inject
} from '@angular/core';
import {
MatTableDataSource,
MatTable,
@@ -26,6 +36,9 @@ import { FormsModule } from '@angular/forms';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FixedDepositsService } from '../../fixed-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
export type FdPeriodUnit = 'Days' | 'Months' | 'Years';
@@ -71,6 +84,9 @@ export interface SlabMatchResult {
})
export class FixedDepositAccountInterestRateChartStepComponent implements OnChanges {
private fixedDepositsService = inject(FixedDepositsService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private cdr = inject(ChangeDetectorRef);
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
@@ -110,7 +126,19 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
'actions'
];
/**
* Derived from the client's own date of birth -- never staff-editable. Setting this by hand is what
* let a 24-year-old's FD get priced at the senior-citizen rate, so the checkbox is display-only and
* this value comes solely from {@link deriveSeniorCitizenStatus}.
*/
isSeniorCitizen = false;
readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
/** Client age in completed years, or null when the DOB is unknown / the client is an entity. */
clientAge: number | null = null;
/** False for a company/entity client, which has no age and so can never qualify. */
clientIsIndividual = true;
/** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */
clientAgeResolved = false;
incentivesDisplayedColumns: string[] = [
'entityType',
'attributeName',
@@ -171,6 +199,9 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
}
ngOnChanges(changes: SimpleChanges) {
if (changes['fixedDepositsAccountTemplate']) {
this.resolveSeniorCitizenFromClient();
}
// Only reset/re-apply when TEMPLATE or cbsBaseRates changes, NOT when initialTenure/submittedOnDate changes alone
const templateChanged = changes['fixedDepositsAccountProductTemplate'] || changes['cbsBaseRates'];
if (templateChanged && this.fixedDepositsAccountProductTemplate) {
@@ -206,10 +237,10 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
if (this.initialTenure.depositAmount) {
this.principalAmount = this.initialTenure.depositAmount;
}
// Pre-check senior citizen if it was set during create
if (this.initialTenure.isSeniorCitizen) {
this.isSeniorCitizen = true;
}
// Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's
// date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a
// previously mis-set flag forward on every edit, which is exactly how a 24-year-old's FD kept its
// senior-citizen rate.
// Auto-calculate when tenure is pre-filled (edit mode)
setTimeout(() => this.calculateRate(), 0);
}
@@ -435,6 +466,45 @@ export class FixedDepositAccountInterestRateChartStepComponent implements OnChan
this.validationWarning = null;
}
/**
* Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template
* arrives (create and edit alike), so the flag always reflects the customer on record rather than
* whatever was ticked -- or previously saved -- by hand.
*/
private resolveSeniorCitizenFromClient(): void {
const clientId = this.fixedDepositsAccountTemplate?.clientId ?? this.fixedDepositsAccountTemplate?.id;
if (!clientId) {
return;
}
this.clientsService.getClientData(String(clientId)).subscribe((client: any) => {
const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate);
this.clientAge = status.age;
this.clientIsIndividual = status.isIndividual;
this.isSeniorCitizen = status.isSeniorCitizen;
this.clientAgeResolved = true;
// Re-price against the now-known status if a tenure was already entered.
this.onSeniorCitizenChange();
this.cdr.detectChanges();
});
}
/** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */
get seniorCitizenHint(): string {
if (!this.clientAgeResolved) {
return 'Determined from the client’s date of birth';
}
if (!this.clientIsIndividual) {
return 'Not applicable to a non-individual client';
}
if (this.clientAge === null) {
return 'Client has no date of birth on record';
}
if (this.isSeniorCitizen) {
return `Client is ${this.clientAge} years old — higher rate applied`;
}
return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`;
}
onSeniorCitizenChange() {
if (this.tenureValue && this.tenureValue > 0) {
this.calculateRate();
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,117 @@
/**
* 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, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Fixed Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-fixed-deposit-account-maturity-terms-step',
templateUrl: './fixed-deposit-account-maturity-terms-step.component.html',
styleUrls: ['./fixed-deposit-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() fixedDepositsAccountTemplate: any;
@Input() fixedDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
if (this.fixedDepositsAccountProductTemplate) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (
this.fixedDepositsAccountProductTemplate.maturityInstructionOptions ?? []
).filter((option: any) => option.id !== 100);
this.savingsAccountsData = this.fixedDepositsAccountProductTemplate.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData =
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: this.fixedDepositsAccountProductTemplate.preClosurePenalApplicable ?? false,
preClosurePenalInterest: this.fixedDepositsAccountProductTemplate.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId:
this.fixedDepositsAccountProductTemplate.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -683,6 +683,41 @@
</div>
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -164,7 +164,26 @@ export class FixedDepositAccountPreviewStepComponent implements OnChanges {
/** Form submission event */
@Output() submitEvent = new EventEmitter();
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const accounts = this.fixedDepositsAccountProductTemplate?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const options = this.fixedDepositsAccountProductTemplate?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
if (this.fixedDepositsAccountProductTemplate) {
@@ -0,0 +1,71 @@
<!--
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/.
-->
<div class="isd-dialog">
<h2 mat-dialog-title class="isd-header">
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
<span class="isd-header__text">
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
</span>
</h2>
<div mat-dialog-content class="isd-content">
<p class="isd-note">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
slightly with the number of days in that month.
</p>
<div class="isd-scroll">
<table class="isd-table">
<thead>
<tr>
<th>{{ 'labels.inputs.From Date' | translate }}</th>
<th>{{ 'labels.inputs.To Date' | translate }}</th>
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
</tr>
</thead>
<tbody>
@for (period of schedule; track $index) {
<tr [class.isd-table__row--paid]="period.paid">
<td>{{ period.fromDate | dateFormat }}</td>
<td>{{ period.toDate | dateFormat }}</td>
<td class="isd-table__amount-col">
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td>
<td class="isd-table__status-col">
@if (period.paid) {
<span class="isd-badge isd-badge--paid">
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
</span>
} @else {
<span class="isd-badge isd-badge--pending">Pending</span>
}
</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
<td class="isd-table__amount-col">
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
</div>
<div mat-dialog-actions align="end" class="isd-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -0,0 +1,142 @@
/**
* 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;
$green: #047857;
$green-bg: #ecfdf5;
$amber: #92400e;
$amber-bg: #fffbeb;
.isd-dialog {
min-width: 560px;
max-width: 720px;
}
.isd-header {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
&__icon {
width: 30px;
height: 30px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
color: #fff;
font-size: 13px;
flex-shrink: 0;
}
&__text {
display: flex;
flex-direction: column;
}
&__title {
font-size: 15px;
font-weight: 700;
color: $navy;
}
&__sub {
font-size: 12px;
color: $muted;
}
}
.isd-content {
max-height: 60vh;
}
.isd-note {
margin: 4px 0 12px;
font-size: 12px;
color: $muted;
font-style: italic;
}
.isd-scroll {
overflow-x: auto;
}
.isd-table {
width: 100%;
border-collapse: collapse;
font-size: 13px;
th,
td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px dashed #eef2f7;
white-space: nowrap;
}
th {
color: $slate;
font-weight: 600;
font-size: 12px;
text-transform: uppercase;
letter-spacing: 0.03em;
background: $soft;
position: sticky;
top: 0;
}
tbody tr:hover {
background: $soft;
}
&__row--paid {
background: rgb(4 120 87 / 4%);
}
tfoot td {
font-weight: 700;
color: $navy;
border-bottom: none;
border-top: 2px solid $border;
}
&__amount-col {
text-align: right;
font-variant-numeric: tabular-nums;
}
&__status-col {
text-align: center;
}
}
.isd-badge {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.02em;
&--paid {
background: $green-bg;
color: $green;
}
&--pending {
background: $amber-bg;
color: $amber;
}
}
@@ -0,0 +1,68 @@
/**
* 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 { CurrencyPipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { DateFormatPipe } from '../../../../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* FD Special's full month-wise interest schedule, in a popup rather than always inline on General --
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
* the engine projects it to be.
*/
@Component({
selector: 'mifosx-interest-schedule-dialog',
templateUrl: './interest-schedule-dialog.component.html',
styleUrls: ['./interest-schedule-dialog.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatDialogTitle,
MatDialogContent,
MatDialogActions,
MatDialogClose,
MatButton,
FaIconComponent,
CurrencyPipe,
DateFormatPipe
]
})
export class InterestScheduleDialogComponent {
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
data: {
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
currencyCode: string;
} = inject(MAT_DIALOG_DATA);
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
return this.data.schedule ?? [];
}
get currencyCode(): string {
return this.data.currencyCode;
}
get totalInterest(): number {
return this.schedule.reduce((sum, row) => sum + (row.interestAmount ?? 0), 0);
}
get paidCount(): number {
return this.schedule.filter((row) => row.paid).length;
}
}
@@ -0,0 +1,191 @@
<!--
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/.
-->
<div class="pmt">
<div class="pmt__header">
<span class="pmt__header-ico"><fa-icon icon="sync-alt"></fa-icon></span>
<div class="pmt__header-text">
<h2 class="pmt__title">{{ 'labels.heading.Payment' | translate }}</h2>
<p class="pmt__subtitle">Process this Fixed Deposit now -- works whether it's Active or Matured.</p>
</div>
</div>
<div mat-dialog-content class="pmt__content">
@if (loadingTemplate) {
<div class="pmt__loading">
<mat-spinner diameter="36"></mat-spinner>
</div>
} @else if (loadFailed) {
<div class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-10"></fa-icon>
Could not load this account's payment options. Close this dialog and try again.
</div>
} @else {
<form [formGroup]="paymentForm">
@if (reinvestCap !== null && reinvestCap !== undefined) {
<div class="pmt__stat">
<span class="pmt__stat-label">
{{
remainderIsApproximate
? 'Amount available on the selected date'
: ('labels.inputs.Maturity Amount' | translate)
}}
</span>
<span class="pmt__stat-value">{{ reinvestCap | formatNumber }}</span>
</div>
}
<mat-form-field appearance="outline" class="pmt__date" (click)="closedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Closed On Date' | translate }}</mat-label>
<input
matInput
[max]="maxDate"
[matDatepicker]="closedOnDatePicker"
required
formControlName="closedOnDate"
/>
<mat-datepicker-toggle matSuffix [for]="closedOnDatePicker"></mat-datepicker-toggle>
<mat-datepicker #closedOnDatePicker></mat-datepicker>
@if (paymentForm.controls.closedOnDate.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Closed On Date' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
<div class="pmt__section-label" id="pmt-action-label">{{ 'labels.inputs.Action' | translate }}</div>
<div class="pmt__options" role="radiogroup" aria-labelledby="pmt-action-label">
@for (closureOption of onAccountClosureOptions; track closureOption) {
<button
type="button"
class="pmt__option"
role="radio"
[attr.aria-checked]="paymentForm.get('onAccountClosureId').value === closureOption.id"
[class.pmt__option--active]="paymentForm.get('onAccountClosureId').value === closureOption.id"
(click)="selectClosureType(closureOption.id)"
>
<span class="pmt__option-ico"><fa-icon [icon]="iconFor(closureOption.id)"></fa-icon></span>
<span class="pmt__option-text">
<span class="pmt__option-title">{{ closureOption.value }}</span>
<span class="pmt__option-desc">{{ descriptionFor(closureOption.id) }}</span>
</span>
@if (paymentForm.get('onAccountClosureId').value === closureOption.id) {
<fa-icon icon="check-circle" class="pmt__option-check"></fa-icon>
}
</button>
}
</div>
@if (
paymentForm.controls.onAccountClosureId.hasError('required') &&
paymentForm.controls.onAccountClosureId.touched
) {
<div class="pmt__option-error">
{{ 'labels.inputs.Account action' | translate }} {{ 'labels.commons.is' | translate }}
{{ 'labels.commons.required' | translate }}
</div>
}
@if (isPartialReinvest) {
<div class="pmt__split">
<mat-form-field appearance="outline" class="pmt__split-input">
<mat-label>{{ 'labels.inputs.Reinvest Amount' | translate }}</mat-label>
<input type="number" matInput required formControlName="reinvestAmount" />
@if (paymentForm.controls.reinvestAmount?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Reinvest Amount' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('min')) {
<mat-error>{{ 'labels.inputs.Reinvest Amount must be greater than zero' | translate }}</mat-error>
}
@if (paymentForm.controls.reinvestAmount?.hasError('belowMaturityAmount')) {
<mat-error>{{
'labels.inputs.Reinvest Amount must be less than the maturity amount' | translate
}}</mat-error>
}
</mat-form-field>
<span class="pmt__split-arrow"><fa-icon icon="arrow-right"></fa-icon></span>
<div class="pmt__split-remainder">
<span class="pmt__split-remainder-label">{{ 'labels.inputs.Remainder to Transfer' | translate }}</span>
<span class="pmt__split-remainder-value">{{
remainderAmount !== null ? (remainderAmount | formatNumber) : '—'
}}</span>
</div>
</div>
@if (remainderIsApproximate) {
<p class="pmt__hint">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
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.
</p>
}
}
@if (noDestinationAccounts) {
<p class="pmt__hint pmt__hint--warn" role="alert">
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
This customer has no active savings account to transfer into. Open one first, or choose an action that keeps
the funds in this deposit.
</p>
}
@if (paymentForm.contains('toSavingsAccountId')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer to Savings' | translate }}</mat-label>
<mat-select required formControlName="toSavingsAccountId">
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
{{ account.accountNo }}
</mat-option>
}
</mat-select>
@if (paymentForm.controls.toSavingsAccountId?.hasError('required')) {
<mat-error>
{{ 'labels.inputs.Transfer to savings account' | translate }} {{ 'labels.commons.is' | translate }}
<strong>{{ 'labels.commons.required' | translate }}</strong>
</mat-error>
}
</mat-form-field>
}
@if (paymentForm.contains('transferDescription')) {
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Transfer Description' | translate }}</mat-label>
<input matInput formControlName="transferDescription" />
</mat-form-field>
}
<mat-form-field appearance="outline">
<mat-label>{{ 'labels.inputs.Note' | translate }}</mat-label>
<textarea matInput formControlName="note" cdkTextareaAutosize cdkAutosizeMinRows="2"></textarea>
</mat-form-field>
@if (submitError) {
<p class="pmt__error" role="alert">
<fa-icon icon="times-circle" class="m-r-5"></fa-icon>
{{ submitError }}
</p>
}
</form>
}
</div>
<div mat-dialog-actions align="end" class="pmt__actions">
<button mat-button mat-dialog-close [disabled]="submitting">{{ 'labels.buttons.Cancel' | translate }}</button>
<button
mat-raised-button
color="primary"
[disabled]="!paymentForm?.valid || submitting || loadFailed"
(click)="submit()"
>
{{ submitting ? ('labels.buttons.Processing' | translate) : ('labels.buttons.Confirm' | translate) }}
</button>
</div>
</div>
@@ -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;
}
@@ -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<number, string> = {
[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<number, string> = {
[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<PaymentDialogComponent>>(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.
* <p>
* 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.';
}
});
}
}
@@ -37,7 +37,7 @@
<span class="fd-hero__meta-item">
<span class="fd-hero__meta-label">{{ 'labels.inputs.Account Number' | translate }}</span>
<mifosx-account-number
accountNo="{{ fixedDepositsAccountData.accountNo }}"
accountNo="{{ fixedDepositsAccountData.fdAccountNumber || fixedDepositsAccountData.accountNo }}"
display="left"
></mifosx-account-number>
</span>
@@ -79,6 +79,16 @@
<!-- Action menu (kebab) -->
<div class="fd-hero__actions">
@if (!fixedDepositsAccountData.status.rejected && !fixedDepositsAccountData.status.submittedAndPendingApproval) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -220,6 +230,19 @@
<span>{{ 'labels.inputs.Transactions' | translate }}</span>
</a>
}
@if (fixedDepositsAccountData.renewalHistory?.length) {
<a
mat-tab-link
class="fd-tab"
[routerLink]="['./renewal-history']"
routerLinkActive
#renewalHistory="routerLinkActive"
[active]="renewalHistory.isActive"
>
<fa-icon icon="history" class="fd-tab__ic"></fa-icon>
<span>{{ 'labels.inputs.Renewal History' | translate }}</span>
</a>
}
<a
mat-tab-link
class="fd-tab"
@@ -16,6 +16,8 @@ import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/conf
import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-interest-dialog/calculate-interest-dialog.component';
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
import { FixedDepositsButtonsConfiguration } from './fixed-deposits-buttons.config';
@@ -98,6 +100,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'interest',
'Enable Withhold Tax': 'interest',
'Disable Withhold Tax': 'interest',
Payment: 'transact',
'Premature Close': 'danger',
'Undo Activation': 'admin',
Close: 'danger',
@@ -121,6 +124,7 @@ export class FixedDepositAccountViewComponent implements OnInit {
'Post Interest': 'percentage',
'Enable Withhold Tax': 'percentage',
'Disable Withhold Tax': 'percentage',
Payment: 'sync-alt',
'Premature Close': 'times-circle',
'Undo Activation': 'undo',
Close: 'times-circle',
@@ -260,9 +264,35 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Terminate':
this.terminateFixedDepositsAccount();
break;
case 'Payment':
this.openPaymentDialog();
break;
}
}
/**
* Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
* Matured, and is the only place a Partial Reinvest can be done.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
width: '640px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
// The server bounds a partial reinvest by the balance AFTER it posts interest to the chosen date,
// so the dialog needs both figures plus the maturity date to know which one that will be.
accountBalance: this.fixedDepositsAccountData.summary?.accountBalance,
maturityDate: this.fixedDepositsAccountData.maturityDate
}
});
paymentDialogRef.afterClosed().subscribe((response: any) => {
if (response?.confirm) {
this.reload();
}
});
}
/**
* Deletes Fixed Deposits Account.
*/
@@ -358,6 +388,20 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'FD',
accountData: this.fixedDepositsAccountData,
clientId: this.fixedDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for fixed deposits account
* Fixed deposits endpoint is not supported so using Savings endpoint.
@@ -116,6 +116,9 @@ export class FixedDepositsButtonsConfiguration {
},
{
name: 'Add Charge'
},
{
name: 'Payment'
}
];
break;
@@ -83,6 +83,22 @@
}}</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedFromAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested From' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedFromLink">{{ fixedDepositsAccountData.reinvestedFromAccountNo }}</a>
</span>
</div>
}
@if (fixedDepositsAccountData.reinvestedIntoAccountNo) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Reinvested Into' | translate }}</span>
<span class="nx-row__value">
<a [routerLink]="reinvestedIntoLink">{{ fixedDepositsAccountData.reinvestedIntoAccountNo }}</a>
</span>
</div>
}
</div>
</div>
@@ -151,11 +167,36 @@
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
<span class="nx-row__label">
{{
(isSpecialFixedDeposit
? 'labels.inputs.Principal Returned at Maturity'
: 'labels.inputs.Maturity Amount'
) | translate
}}
</span>
<span class="nx-row__value nx-row__value--amount" [class.nx-row__value--positive]="!isSpecialFixedDeposit">
{{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (isSpecialFixedDeposit && nextInterestPayout !== null) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Next Interest Payout' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (isSpecialFixedDeposit && totalValueAtMaturity !== null) {
<div class="nx-row">
<span class="nx-row__label">{{
'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate
}}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (fixedDepositsAccountData.summary.totalDeposits) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Deposits' | translate }}</span>
@@ -205,13 +246,63 @@
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
<span class="nx-row__label">
{{
(isSpecialFixedDeposit
? 'labels.inputs.Principal Returned at Maturity'
: 'labels.inputs.Maturity Amount'
) | translate
}}
</span>
<span class="nx-row__value nx-row__value--amount">
{{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
@if (isSpecialFixedDeposit && nextInterestPayout !== null) {
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Next Interest Payout' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
@if (isSpecialFixedDeposit && totalValueAtMaturity !== null) {
<div class="nx-row">
<span class="nx-row__label">{{
'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate
}}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
}
</div>
</div>
}
</div>
</div>
@if (isSpecialFixedDeposit && projectedInterestSchedule.length) {
<div class="nx-card gt-schedule">
<div class="nx-card__head">
<span class="nx-card__ico"><fa-icon icon="calendar-alt"></fa-icon></span>
<h3 class="nx-card__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</h3>
</div>
<div class="nx-card__body gt-schedule__body">
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Payable' | translate }}</span>
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
{{ totalProjectedInterest | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
</span>
</div>
<div class="nx-row">
<span class="nx-row__label">{{ 'labels.inputs.Periods Paid So Far' | translate }}</span>
<span class="nx-row__value">{{ paidPeriodsCount }} / {{ projectedInterestSchedule.length }}</span>
</div>
<button mat-stroked-button color="primary" class="gt-schedule__view-btn" (click)="openInterestSchedule()">
<fa-icon icon="table" class="m-r-5"></fa-icon>
{{ 'labels.buttons.View Full Schedule' | translate }}
</button>
</div>
</div>
}
@@ -118,3 +118,13 @@ $soft: #f8fafc;
font-weight: 500;
font-style: italic;
}
/* ─── Monthly Interest Payout Schedule summary (FD Special only) ─────── */
.gt-schedule {
margin-top: 16px;
&__view-btn {
width: 100%;
margin-top: 8px;
}
}
@@ -8,12 +8,14 @@
import { Component, 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 { ExternalIdentifierComponent } from '../../../../shared/external-identifier/external-identifier.component';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { InterestScheduleDialogComponent } from '../custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component';
@Component({
selector: 'mifosx-general-tab',
@@ -30,6 +32,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class GeneralTabComponent {
private route = inject(ActivatedRoute);
private router = inject(Router);
private dialog = inject(MatDialog);
fixedDepositsAccountData: any;
entityType: string;
@@ -48,4 +51,79 @@ export class GeneralTabComponent {
this.entityType = 'Center';
}
}
/**
* 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';
}
get reinvestedFromLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedFromAccountId;
return id ? this.siblingAccountLink(id) : null;
}
get reinvestedIntoLink(): string | null {
const id = this.fixedDepositsAccountData?.reinvestedIntoAccountId;
return id ? this.siblingAccountLink(id) : null;
}
/** FD Special never compounds -- every month's interest is transferred out to the linked savings
* account instead of growing this account's own balance, so its "Maturity Amount" is just the
* principal, not principal-plus-interest (see FixedDepositAccount#updateMaturityDateAndAmount).
*/
get isSpecialFixedDeposit(): boolean {
return this.fixedDepositsAccountData?.fdType === 'SPECIAL';
}
/** Real, engine-computed period-by-period interest schedule for FD Special (see FixedDepositAccountsApiResource#retrieveOne). */
get projectedInterestSchedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
return this.fixedDepositsAccountData?.projectedInterestSchedule ?? [];
}
get paidPeriodsCount(): number {
return this.projectedInterestSchedule.filter((period) => period.paid).length;
}
/** Opens the full month-wise schedule in a popup rather than always inline -- a 24+ month term makes
* for a long table that would otherwise push every other card on this tab down the page. */
openInterestSchedule(): void {
this.dialog.open(InterestScheduleDialogComponent, {
width: '680px',
data: {
schedule: this.projectedInterestSchedule,
currencyCode: this.currency?.code
}
});
}
/** Backend-computed sum of the schedule (see FixedDepositAccountsApiResource#retrieveOne) -- the single
* source of truth, not a client-side re-sum of the schedule array. */
get totalProjectedInterest(): number | null {
return this.fixedDepositsAccountData?.totalProjectedInterest ?? null;
}
/**
* The first NOT-YET-PAID period's real amount -- not just index 0, which would keep showing an
* already-posted-and-transferred period's amount as "next" forever once the account has any real
* history. Deliberately has NO rate/12 fallback: that would quietly present an inaccurate money figure
* as if it were the real one. If the schedule is missing, or every period is already paid, the row is
* hidden instead.
*/
get nextInterestPayout(): number | null {
return this.projectedInterestSchedule.find((period) => !period.paid)?.interestAmount ?? null;
}
/** What the customer ends up with overall across the FD's whole life: the principal returned by the FD
* itself at maturity, PLUS every month's interest already paid out to the linked savings account along
* the way. Backend-computed (see FixedDepositAccountsApiResource#retrieveOne), same as maturityAmount
* itself, rather than derived here from the schedule array. */
get totalValueAtMaturity(): number | null {
return this.fixedDepositsAccountData?.totalValueAtMaturity ?? null;
}
}
@@ -36,6 +36,17 @@
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.annualInterestRate | formatNumber }} %</td>
</ng-container>
<ng-container matColumnDef="seniorCitizen">
<th mat-header-cell *matHeaderCellDef><fa-icon icon="user-shield" class="m-r-5"></fa-icon>Senior Citizen</th>
<td mat-cell *matCellDef="let chartSlab">
{{
chartSlab.seniorCitizenRate !== null && chartSlab.seniorCitizenRate !== undefined
? (chartSlab.seniorCitizenRate | formatNumber) + ' %'
: '-'
}}
</td>
</ng-container>
<ng-container matColumnDef="description">
<th mat-header-cell *matHeaderCellDef>{{ 'labels.inputs.Description' | translate }}</th>
<td mat-cell *matCellDef="let chartSlab">{{ chartSlab.description }}</td>
@@ -71,6 +71,7 @@ export class InterestRateChartTabComponent {
'period',
'amountRange',
'interest',
'seniorCitizen',
'description',
'actions'
];
@@ -0,0 +1,139 @@
<!--
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/.
-->
<div class="rh-container">
@if (renewalHistory.length) {
<!-- ─── Summary strip ──────────────────────────────────────────── -->
<div class="rh-summary">
<div class="rh-summary__item">
<span class="rh-summary__label">FD Number</span>
<span class="rh-summary__value">{{ fdNumber }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Terms So Far</span>
<span class="rh-summary__value">{{ renewalHistory.length }}</span>
</div>
<div class="rh-summary__item">
<span class="rh-summary__label">Original Principal</span>
<span class="rh-summary__value">{{ originalPrincipal | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--highlight">
<span class="rh-summary__label">Current Value</span>
<span class="rh-summary__value">{{ currentValue | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-summary__item rh-summary__item--positive">
<span class="rh-summary__label">Lifetime Interest</span>
<span class="rh-summary__value">{{ lifetimeInterestEarned | currency: currency.code : 'symbol-narrow' }}</span>
</div>
</div>
<!-- ─── Vertical timeline ──────────────────────────────────────── -->
<div class="rh-timeline">
@for (
term of renewalHistory;
track term.historyid ?? term.accountid + '-' + term.renewalcycleno;
let last = $last
) {
<div class="rh-node">
<div class="rh-node__rail">
<span class="rh-node__dot" [class.rh-node__dot--active]="term.renewalstatus === 'ACTIVE'">
@if (term.renewalstatus === 'ACTIVE') {
<span class="rh-node__pulse"></span>
}
</span>
@if (!last) {
<span class="rh-node__line"></span>
}
</div>
<div class="rh-card" [class.rh-card--active]="term.renewalstatus === 'ACTIVE'">
<div class="rh-card__head">
<div class="rh-card__title-group">
<h4 class="rh-card__title">
{{ term.renewalcycleno === 0 ? 'Original Term' : 'Renewal ' + term.renewalcycleno }}
</h4>
<span class="rh-badge" [class.rh-badge--active]="term.renewalstatus === 'ACTIVE'">
<fa-icon [icon]="term.renewalstatus === 'ACTIVE' ? 'check-circle' : 'clock'"></fa-icon>
{{ term.renewalstatus === 'ACTIVE' ? 'Active' : 'Matured' }}
</span>
@if (renewalTypeLabel(term)) {
<span class="rh-tag">
<fa-icon [icon]="renewalTypeIcon(term)"></fa-icon>
{{ renewalTypeLabel(term) }}
</span>
}
</div>
<button type="button" class="rh-chart-btn" (click)="openRateChart(term)">
<fa-icon icon="table"></fa-icon>
View Rate Chart
</button>
</div>
<div class="rh-card__period">
<fa-icon icon="calendar-alt" class="rh-card__period-ico"></fa-icon>
<span>{{ term.activatedondate | dateFormat }}</span>
<fa-icon icon="arrow-right" class="rh-card__period-arrow"></fa-icon>
<span>{{ term.maturitydate | dateFormat }}</span>
@if (tenureLabel(term)) {
<span class="rh-card__tenure">({{ tenureLabel(term) }})</span>
}
</div>
<div class="rh-stats">
<div class="rh-stat">
<span class="rh-stat__label">Principal</span>
<span class="rh-stat__value">{{ term.depositamount | currency: currency.code : 'symbol-narrow' }}</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">Rate</span>
<span class="rh-stat__value">{{ term.interestrate }}%</span>
</div>
<div class="rh-stat">
<span class="rh-stat__label">{{
term.renewalstatus === 'ACTIVE' ? 'Interest So Far' : 'Interest Earned'
}}</span>
<span class="rh-stat__value rh-stat__value--positive">
{{
term.interestearned === null || term.interestearned === undefined
? '—'
: (term.interestearned | currency: currency.code : 'symbol-narrow')
}}
</span>
</div>
<div class="rh-stat rh-stat--highlight">
<span class="rh-stat__label">{{
term.maturedamount === null || term.maturedamount === undefined ? 'Projected Maturity' : 'Matured At'
}}</span>
<span class="rh-stat__value">
{{ maturityFor(term) | currency: currency.code : 'symbol-narrow' }}
</span>
</div>
</div>
<div class="rh-card__foot">
<fa-icon icon="info-circle" class="rh-card__foot-ico"></fa-icon>
<span>
Ledger account:
@if (term.accountid === fixedDepositsAccountData.id) {
<strong>{{ term.accountno }}</strong>
} @else {
<a [routerLink]="siblingLink(term.accountid)">{{ term.accountno }}</a>
}
</span>
</div>
</div>
</div>
}
</div>
} @else {
<div class="rh-empty">
<fa-icon icon="history" class="rh-empty__icon"></fa-icon>
<p class="rh-empty__text">{{ 'labels.messages.No Renewal History Available' | translate }}</p>
</div>
}
</div>
@@ -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;
}
}
@@ -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<number, string> = {
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
}
});
}
}
@@ -0,0 +1,77 @@
<!--
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/.
-->
<div class="trc-dialog">
<h2 mat-dialog-title class="trc-header">
<span class="trc-header__icon"><fa-icon icon="table"></fa-icon></span>
<span class="trc-header__text">
<span class="trc-header__title">{{ termLabel }} — Rate Chart</span>
<span class="trc-header__sub"
>{{ term.activatedondate | dateFormat }} → {{ term.maturitydate | dateFormat }}</span
>
</span>
</h2>
<div mat-dialog-content class="trc-content">
@if (slabs.length) {
<table class="trc-table">
<thead>
<tr>
<th>Description</th>
<th>Tenure</th>
<th class="trc-table__rate-col">Rate</th>
<th class="trc-table__rate-col">
<fa-icon icon="user-shield" class="trc-table__sc-ico"></fa-icon>
Senior Citizen
</th>
</tr>
</thead>
<tbody>
@for (slab of slabs; track $index) {
<tr [class.trc-table__row--applied]="isAppliedSlab(slab) || isAppliedSeniorSlab(slab)">
<td>{{ slab.description }}</td>
<td>{{ slab.fromPeriod }} – {{ slab.toPeriod || '∞' }} {{ slab.periodTypeLabel }}</td>
<td class="trc-table__rate-col">
{{ slab.annualInterestRate }}%
@if (isAppliedSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
<td class="trc-table__rate-col trc-table__sc-col">
{{
slab.seniorCitizenRate !== null && slab.seniorCitizenRate !== undefined
? slab.seniorCitizenRate + '%'
: '—'
}}
@if (isAppliedSeniorSlab(slab)) {
<span class="trc-applied-badge">Applied</span>
}
</td>
</tr>
}
</tbody>
</table>
@if (hasLiveSeniorRate) {
<p class="trc-note">
<fa-icon icon="info-circle"></fa-icon>
Senior citizen rate for this term wasn't recorded at the time; the figure shown is today's published rate, for
reference.
</p>
}
} @else {
<div class="trc-empty">
<fa-icon icon="info-circle" class="trc-empty__icon"></fa-icon>
<p>No rate chart was archived for this term.</p>
</div>
}
</div>
<div mat-dialog-actions align="end" class="trc-actions">
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
</div>
</div>
@@ -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;
}
@@ -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<TermRateChartDialogComponent>>(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)
);
}
}
@@ -36,7 +36,11 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.id }}
@if (transaction.isRenewalEvent) {
<fa-icon icon="sync-alt" class="txn-tab__renewal-ico"></fa-icon>
} @else {
{{ transaction.id }}
}
</td>
</ng-container>
@@ -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'
}}
</td>
</ng-container>
@@ -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'
}}
</td>
</ng-container>
@@ -96,36 +112,40 @@
[ngClass]="savingsTransactionColor(transaction)"
(click)="showTransactions(transaction)"
>
{{ transaction.runningBalance | formatNumber }}
{{ transaction.isRenewalEvent ? '—' : (transaction.runningBalance | formatNumber) }}
</td>
</ng-container>
<ng-container matColumnDef="actions">
<th mat-header-cell class="center" *matHeaderCellDef>{{ 'labels.inputs.Actions' | translate }}</th>
<td mat-cell class="center" *matCellDef="let transaction; let idx = index">
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
@if (transaction.isRenewalEvent) {
<span class="txn-tab__renewal-tag">No ledger impact</span>
} @else {
<button mat-icon-button [matMenuTriggerFor]="transactionMenu" aria-label="" class="action-button">
<mat-icon>more_vert</mat-icon>
</button>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
<mat-menu #transactionMenu="matMenu">
<button mat-menu-item (click)="showTransactions(transaction)">
<mat-icon><fa-icon icon="eye" size="sm"></fa-icon></mat-icon>
<span>{{ 'labels.text.View Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
@if (!isFirstTransaction(idx)) {
<button mat-menu-item (click)="undoTransaction(transaction)">
<mat-icon><fa-icon icon="undo" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.Undo Transaction' | translate }}</span>
</button>
}
<button
mat-menu-item
(click)="routeEdit($event)"
[routerLink]="['/', 'journal-entry', 'view', 'S' + transaction.id]"
>
<mat-icon><fa-icon icon="table-cells" size="sm"></fa-icon></mat-icon>
<span>{{ 'tooltips.View Journal Entries' | translate }}</span>
</button>
</mat-menu>
}
</td>
</ng-container>
@@ -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;
}
@@ -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) {
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveFixedDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the fixed deposit account,
* if successful redirects to the fixed deposit account.
* Submits the form, approves the fixed deposit account, then immediately activates it on the
* same date -- Approve and Activate are still two separate Fineract commands underneath (each
* shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveFixedDepositsAccountFormData = this.approveFixedDepositsAccountForm.value;
@@ -98,7 +99,16 @@ export class ApproveFixedDepositsAccountComponent implements OnInit {
locale
};
this.fixedDepositsService.executeFixedDepositsAccountCommand(this.accountId, 'approve', data).subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveFixedDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -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,
@@ -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<any>}
*/
getFixedDepositsAccountPaymentTemplate(accountId: string): Observable<any> {
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<any>}
@@ -33,6 +33,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -111,7 +112,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -132,6 +166,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -24,6 +24,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -40,6 +41,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -61,6 +63,12 @@ export class CreateRecurringDepositsAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Recurring Deposits Account Template */
recurringDepositsAccountTemplate: any;
/** Recurring Deposit Account Product Template */
@@ -68,7 +76,7 @@ export class CreateRecurringDepositsAccountComponent {
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
/** Total wizard tabs — Product, Currency, Chart, Preview. */
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -258,6 +266,7 @@ export class CreateRecurringDepositsAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -35,6 +35,7 @@
<mifosx-recurring-deposits-account-details-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
(recurringDepositsAccountProductTemplate)="setTemplate($event)"
[externalLinkedAccountId]="recurringDepositsAccountMaturityTermsStep?.selectedTransferAccount?.id ?? null"
>
</mifosx-recurring-deposits-account-details-step>
@@ -115,7 +116,40 @@
</div>
</mat-tab>
<!-- Tab 4: Preview -->
<!-- Tab 4: Terms -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="file-alt" class="cra-tab__icon"></fa-icon>
Terms
</ng-template>
<div class="cra-tab-body">
@if (recurringDepositsAccountProductTemplate) {
<mifosx-recurring-deposits-account-maturity-terms-step
[recurringDepositsAccountTemplate]="recurringDepositsAccountAndTemplate"
[recurringDepositsAccountProductTemplate]="recurringDepositsAccountProductTemplate"
[linkedSavingsAccountId]="
recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.accountType === 'SAVINGS'
? recurringDepositsAccountDetailsStep?.selectedLinkedAccount?.id
: null
"
></mifosx-recurring-deposits-account-maturity-terms-step>
}
<div class="cra-actions">
<button mat-stroked-button (click)="prev()">
<fa-icon icon="arrow-left" class="m-r-10"></fa-icon>
{{ 'labels.buttons.Previous' | translate }}
</button>
<span class="cra-actions__spacer"></span>
<button mat-raised-button color="primary" (click)="next()">
{{ 'labels.buttons.Next' | translate }}
<fa-icon icon="arrow-right" class="m-l-10"></fa-icon>
</button>
</div>
</div>
</mat-tab>
<!-- Tab 5: Preview -->
<mat-tab [disabled]="!recurringDepositsAccountProductTemplate">
<ng-template mat-tab-label>
<fa-icon icon="eye" class="cra-tab__icon"></fa-icon>
@@ -136,6 +170,7 @@
[rdDaysInYear]="rdDaysInYear"
[rdAppliedRate]="rdAppliedRate"
[rdIsSeniorCitizen]="rdIsSeniorCitizen"
[closureSettings]="recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}"
[hideButtons]="true"
(submitEvent)="submit()"
>
@@ -25,6 +25,7 @@ import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RecurringDepositsAccountCurrencyStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-currency-step/recurring-deposits-account-currency-step.component';
import { RecurringDepositsAccountInterestRateChartStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component';
import { RecurringDepositsAccountPreviewStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component';
import { RecurringDepositsAccountMaturityTermsStepComponent } from '../recurring-deposits-account-stepper/recurring-deposits-account-maturity-terms-step/recurring-deposits-account-maturity-terms-step.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -41,6 +42,7 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
RecurringDepositsAccountDetailsStepComponent,
RecurringDepositsAccountCurrencyStepComponent,
RecurringDepositsAccountInterestRateChartStepComponent,
RecurringDepositsAccountMaturityTermsStepComponent,
RecurringDepositsAccountPreviewStepComponent
]
})
@@ -63,13 +65,19 @@ export class EditRecurringDepositAccountComponent {
@ViewChild(RecurringDepositsAccountInterestRateChartStepComponent)
recurringDepositsAccountChartStep: RecurringDepositsAccountInterestRateChartStepComponent;
@ViewChild(RecurringDepositsAccountPreviewStepComponent)
recurringDepositsAccountPreviewStep: RecurringDepositsAccountPreviewStepComponent;
@ViewChild(RecurringDepositsAccountMaturityTermsStepComponent)
recurringDepositsAccountMaturityTermsStep: RecurringDepositsAccountMaturityTermsStepComponent;
/** Full account data + template from resolver */
recurringDepositsAccountAndTemplate: any;
/** Product template emitted by details step */
recurringDepositsAccountProductTemplate: any;
/** Currently visible wizard tab (0-indexed). */
selectedTabIndex = 0;
private readonly totalTabs = 4;
private readonly totalTabs = 5;
/** Active CBS base interest rate versions */
cbsBaseRates: any[] = [];
/** Selected tenure value and frequency from calculator */
@@ -277,6 +285,7 @@ export class EditRecurringDepositAccountComponent {
...details,
...terms,
...settings,
...(this.recurringDepositsAccountMaturityTermsStep?.closureSettings ?? {}),
...productDefaults,
clientId: this.recurringDepositsAccountAndTemplate.clientId,
mandatoryRecommendedDepositAmount: pt?.minDepositAmount ?? 0,
@@ -10,6 +10,7 @@
<mat-card>
<form [formGroup]="approveRecurringDepositsAccountForm" (ngSubmit)="submit()">
<mat-card-content>
<p class="mat-body-2">This approves and activates the account in one step -- it goes straight to Active.</p>
<div class="layout-column">
<mat-form-field (click)="approvedOnDatePicker.open()">
<mat-label>{{ 'labels.inputs.Approved On Date' | translate }}</mat-label>
@@ -81,8 +81,9 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
}
/**
* Submits the form and approves the recurring deposit account,
* if successful redirects to the recurring deposit account.
* Submits the form, approves the recurring deposit account, then immediately activates it on
* the same date -- Approve and Activate are still two separate Fineract commands underneath
* (each shows up in the audit trail), but the officer only sees and clicks through one step.
*/
submit() {
const approveRecurringDepositsAccountFormData = this.approveRecurringDepositsAccountForm.value;
@@ -103,7 +104,16 @@ export class ApproveRecurringDepositsAccountComponent implements OnInit {
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'approve', data)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
const activationData = {
activatedOnDate: approveRecurringDepositsAccountFormData.approvedOnDate,
dateFormat,
locale
};
this.recurringDepositsService
.executeRecurringDepositsAccountCommand(this.accountId, 'activate', activationData)
.subscribe(() => {
this.router.navigate(['../../'], { relativeTo: this.route });
});
});
}
}
@@ -613,7 +613,7 @@
<mat-form-field appearance="outline" class="soa-field">
<mat-label>Age</mat-label>
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
<mat-hint>Must be below 18 years</mat-hint>
<mat-hint>Must be below {{ minorAgeThreshold }} years</mat-hint>
</mat-form-field>
<mat-form-field appearance="outline" class="soa-field">
@@ -7,7 +7,17 @@
*/
/** Angular Imports */
import { Component, OnInit, Input, Output, EventEmitter, inject, ChangeDetectorRef } from '@angular/core';
import {
Component,
OnInit,
OnChanges,
SimpleChanges,
Input,
Output,
EventEmitter,
inject,
ChangeDetectorRef
} from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, Validators, FormsModule } from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
@@ -16,6 +26,12 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import {
AGE_OF_MAJORITY,
MINOR_AGE_THRESHOLD,
isMinorAge,
isSeniorCitizenAge
} from 'app/clients/shared/client-age.util';
import { MatTooltip } from '@angular/material/tooltip';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
@@ -43,7 +59,7 @@ import { IntroducerSearchComponent } from 'app/shared/introducer-search/introduc
IntroducerSearchComponent
]
})
export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnChanges {
private formBuilder = inject(UntypedFormBuilder);
private recurringDepositsService = inject(RecurringDepositsService);
private settingsService = inject(SettingsService);
@@ -56,6 +72,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
/** Recurring Deposits Account Template */
@Input() recurringDepositsAccountTemplate: any;
/** Linked-account id picked elsewhere in the wizard (e.g. the Terms step's Transfer to Savings
* account) -- pre-fills this card so the officer doesn't have to pick the same account twice. */
@Input() externalLinkedAccountId: number | null = null;
/** Minimum date allowed. */
minDate = new Date(2000, 0, 1);
/** Maximum date allowed. */
@@ -83,8 +103,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
selectedCustomerSegment: string | null = null;
// ── Client info (auto-fetched) ─────────────────────────────────────────
/**
* The client's legal form ("Individual" / "Non-Individual") as captured at client creation. Customer
* Segment is derived from this rather than picked by hand, so an account can never disagree with the
* CIF about what kind of customer it belongs to.
*/
clientLegalForm: string | null = null;
clientDob: Date | null = null;
clientAge: number | null = null;
/** Exposed for tooltips so the displayed rule can never drift from the applied rule. */
readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
clientGender: string | null = null;
primaryClientName = '';
primaryClientCif = '';
@@ -198,6 +226,23 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
this.dalSearch = '';
}
ngOnChanges(changes: SimpleChanges): void {
if (!changes.externalLinkedAccountId || this.selectedLinkedAccount) {
return;
}
const id = this.externalLinkedAccountId;
if (!id) return;
const savings = this.dalAllSavings.find((a: any) => a.id === id);
const current = this.dalAllCurrent.find((a: any) => a.id === id);
if (savings) {
this.dalSelectedType = 'SAVINGS';
this.selectedLinkedAccount = savings;
} else if (current) {
this.dalSelectedType = 'CURRENT';
this.selectedLinkedAccount = current;
}
}
private loadClientAccounts(): void {
const clientId = String(this.recurringDepositsAccountTemplate?.clientId);
if (!clientId) return;
@@ -306,6 +351,8 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
this.autoDetectAccountStatus();
}
}
this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null;
this.applyInferredCustomerSegment();
this.cdr.detectChanges();
});
}
@@ -333,16 +380,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
}
isTileDisabled(value: string): boolean {
if (value === 'MINOR') return this.clientAge === null || this.clientAge >= 18;
if (value === 'SENIOR_CITIZEN') return this.clientAge === null || this.clientAge < 60;
if (value === 'MINOR') return !isMinorAge(this.clientAge);
if (value === 'SENIOR_CITIZEN') return !isSeniorCitizenAge(this.clientAge);
return false;
}
getTileTooltip(value: string): string {
if (value === 'MINOR' && this.clientAge !== null && this.clientAge >= 18) {
return `Age ${this.clientAge} yrs — Minor only allowed below 18 years`;
if (value === 'MINOR' && !isMinorAge(this.clientAge)) {
return `Age ${this.clientAge} yrs — Minor only allowed below years`;
}
if (value === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60) {
if (value === 'SENIOR_CITIZEN' && !isSeniorCitizenAge(this.clientAge)) {
return `Age ${this.clientAge} yrs — Senior Citizen only allowed at 60+ years`;
}
if (value === 'MINOR' && this.clientAge === null) return 'DOB not available — cannot verify age';
@@ -355,10 +402,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
return;
}
if (this.clientAge === null) return;
if (this.clientAge < 18) {
if (isMinorAge(this.clientAge)) {
this.selectedAccountStatus = 'MINOR';
this.minorDob = this.clientDob;
} else if (this.clientAge >= 60) {
} else if (isSeniorCitizenAge(this.clientAge)) {
this.selectedAccountStatus = 'SENIOR_CITIZEN';
} else if (this.clientGender === 'female') {
this.selectedAccountStatus = 'INDIVIDUAL_FEMALE';
@@ -410,6 +457,9 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
this.customerSegmentOptions = this.extractOptions(response.customerSegmentOptions, response.customerSegment);
this.selectedAccountType = response.accountType ?? null;
this.selectedCustomerSegment = response.customerSegment ?? null;
// The product default above is only a fallback -- the client's own legal form wins. Re-applied
// here as well as in the client callback because the two responses can arrive in either order.
this.applyInferredCustomerSegment();
this.recurringDepositsAccountProductTemplate.emit(response);
this.fieldOfficerData = response.fieldOfficerOptions;
this.isProductSelected = true;
@@ -526,6 +576,24 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
return fallback ? [fallback] : [];
}
/**
* Selects the Customer Segment that matches the client's legal form. The segment code values
* ("Individual" / "Non-Individual") are seeded to be byte-identical to the legal-form labels, so this
* is an exact, case-insensitive comparison on purpose -- a substring match would resolve
* "Non-Individual" to the "Individual" option, since one contains the other. Leaves the product
* default untouched when the client's form is unknown or has no matching option.
*/
private applyInferredCustomerSegment(): void {
if (!this.clientLegalForm || !this.customerSegmentOptions.length) {
return;
}
const target = this.clientLegalForm.trim().toLowerCase();
const match = this.customerSegmentOptions.find((opt) => String(opt).trim().toLowerCase() === target);
if (match) {
this.selectedCustomerSegment = match;
}
}
formatTemplateDate(value: any): string {
if (!value) return '';
let dateValue: Date | null = null;
@@ -664,7 +732,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit {
const dob = this.minorDob ?? this.clientDob;
if (!dob) return '';
const d = new Date(dob);
d.setFullYear(d.getFullYear() + 18);
d.setFullYear(d.getFullYear() + AGE_OF_MAJORITY);
return this.dateUtils.formatDate(d, 'M/d/yyyy') || '';
}
@@ -110,12 +110,12 @@
<!-- Senior Citizen -->
<div class="fdrc-calc-field fdrc-calc-field--senior">
<label class="fdrc-senior-label">
<input type="checkbox" [(ngModel)]="isSeniorCitizen" (change)="onSeniorCitizenChange()" />
<input type="checkbox" [ngModel]="isSeniorCitizen" [disabled]="true" />
<span class="fdrc-senior-text">
<i class="fas fa-user-shield"></i>
Senior Citizen
</span>
<span class="fdrc-senior-hint">Higher rate applied if available</span>
<span class="fdrc-senior-hint">{{ seniorCitizenHint }}</span>
</label>
</div>
@@ -6,8 +6,21 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, Input, OnChanges, SimpleChanges, ViewChild, Output, EventEmitter, inject } from '@angular/core';
import {
Component,
Input,
OnChanges,
SimpleChanges,
ViewChild,
Output,
EventEmitter,
ChangeDetectorRef,
inject
} from '@angular/core';
import { RecurringDepositsService } from '../../recurring-deposits.service';
import { ClientsService } from 'app/clients/clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { SENIOR_CITIZEN_AGE_THRESHOLD, deriveSeniorCitizenStatus } from 'app/clients/shared/client-age.util';
import {
MatTable,
MatColumnDef,
@@ -142,7 +155,18 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
'actions'
];
/**
* Derived from the client's own date of birth -- never staff-editable, so a client who is not
* actually 60+ can never be priced at the senior-citizen rate by a mis-ticked box.
*/
isSeniorCitizen = false;
readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
/** Client age in completed years, or null when the DOB is unknown / the client is an entity. */
clientAge: number | null = null;
/** False for a company/entity client, which has no age and so can never qualify. */
clientIsIndividual = true;
/** True once the client lookup has come back, so the UI can explain the locked checkbox honestly. */
clientAgeResolved = false;
incentivesDisplayedColumns: string[] = [
'entityType',
'attributeName',
@@ -172,8 +196,14 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
];
private recurringDepositsService = inject(RecurringDepositsService);
private clientsService = inject(ClientsService);
private settingsService = inject(SettingsService);
private cdr = inject(ChangeDetectorRef);
ngOnChanges(changes: SimpleChanges) {
if (changes['recurringDepositsAccountTemplate']) {
this.resolveSeniorCitizenFromClient();
}
// Only reset/re-apply when the TEMPLATE or cbsBaseRates changes, NOT when initialTenure changes alone
const templateChanged =
changes['recurringDepositsAccountProductTemplate'] ||
@@ -201,10 +231,9 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
if (this.initialTenure.depositAmount) {
this.principalAmount = this.initialTenure.depositAmount;
}
// Pre-check senior citizen if it was set during create
if (this.initialTenure.isSeniorCitizen) {
this.isSeniorCitizen = true;
}
// Deliberately NOT restoring initialTenure.isSeniorCitizen: the flag is derived from the client's
// date of birth in resolveSeniorCitizenFromClient(). Re-applying a stored value here would carry a
// previously mis-set flag forward on every edit.
// Auto-calculate when tenure is pre-filled (edit mode)
setTimeout(() => this.calculateRate(), 0);
}
@@ -359,6 +388,45 @@ export class RecurringDepositsAccountInterestRateChartStepComponent implements O
this.validationWarning = null;
}
/**
* Derives the senior-citizen flag from the client's date of birth. Runs whenever the account template
* arrives (create and edit alike), so the flag always reflects the customer on record rather than
* whatever was ticked -- or previously saved -- by hand.
*/
private resolveSeniorCitizenFromClient(): void {
const clientId = this.recurringDepositsAccountTemplate?.clientId ?? this.recurringDepositsAccountTemplate?.id;
if (!clientId) {
return;
}
this.clientsService.getClientData(String(clientId)).subscribe((client: any) => {
const status = deriveSeniorCitizenStatus(client, this.settingsService.businessDate);
this.clientAge = status.age;
this.clientIsIndividual = status.isIndividual;
this.isSeniorCitizen = status.isSeniorCitizen;
this.clientAgeResolved = true;
// Re-price against the now-known status if a tenure was already entered.
this.onSeniorCitizenChange();
this.cdr.detectChanges();
});
}
/** Explains the locked checkbox to staff, so a missing benefit never looks like a system fault. */
get seniorCitizenHint(): string {
if (!this.clientAgeResolved) {
return 'Determined from the client’s date of birth';
}
if (!this.clientIsIndividual) {
return 'Not applicable to a non-individual client';
}
if (this.clientAge === null) {
return 'Client has no date of birth on record';
}
if (this.isSeniorCitizen) {
return `Client is ${this.clientAge} years old — higher rate applied`;
}
return `Client is ${this.clientAge} — applies from age ${this.seniorCitizenAgeThreshold}`;
}
onSeniorCitizenChange() {
if (this.tenureValue && this.tenureValue > 0) {
this.calculateRate();
@@ -0,0 +1,108 @@
<!--
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/.
-->
<form [formGroup]="closureSettingsForm" class="layout-row-wrap gap-2px responsive-column">
<h4 class="mat-h4 flex-98">{{ 'labels.heading.For Pre-mature closure' | translate }}</h4>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Maturity Instructions' | translate }}</mat-label>
<mat-select formControlName="maturityInstructionId">
@for (maturityInstruction of maturityInstructionOptions; track maturityInstruction) {
<mat-option [value]="maturityInstruction.id">
{{ maturityInstruction.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
@if (closureSettingsForm.contains('transferToSavingsId')) {
<div class="dal-card flex-98">
<div class="dal-card__header">
<div class="dal-card__header-left">
<div class="dal-card__icon">
<mat-icon>savings</mat-icon>
</div>
<div>
<h3 class="dal-card__title">{{ 'labels.inputs.Transfer to savings account' | translate }}</h3>
<span class="dal-card__sub">Maturity proceeds will be credited to this account</span>
</div>
</div>
</div>
<div class="dal-card__body">
@if (selectedTransferAccount) {
<div class="dal-selected">
<div class="dal-selected__info">
<span class="dal-type-badge dal-type-badge--savings">
<mat-icon>savings</mat-icon>
Savings
</span>
<div class="dal-selected__details">
<span class="dal-selected__accno">{{ selectedTransferAccount.accountNo }}</span>
<span class="dal-selected__product">{{ selectedTransferAccount.savingsProductName }}</span>
</div>
@if (selectedTransferAccount.summary?.accountBalance != null) {
<div class="dal-selected__balance">
<span class="dal-selected__bal-label">Balance</span>
<span class="dal-selected__bal-amount">
{{ selectedTransferAccount.currency?.code }}
{{ selectedTransferAccount.summary.accountBalance | number: '1.2-2' }}
</span>
</div>
}
</div>
<button
mat-stroked-button
color="primary"
type="button"
class="dal-change-btn"
(click)="clearTransferAccount()"
>
<mat-icon>swap_horiz</mat-icon>
Change
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="flex-98">
<mat-label>{{ 'labels.inputs.Transfer to savings account' | translate }}</mat-label>
<mat-select formControlName="transferToSavingsId" required>
@for (account of savingsAccountsData; track account) {
<mat-option [value]="account.id">
({{ account.accountNo }}) {{ account.savingsProductName }}
</mat-option>
}
</mat-select>
<mat-error>Transfer to savings account is required</mat-error>
</mat-form-field>
}
</div>
</div>
}
<mat-checkbox class="flex-98 margin-v" labelPosition="before" formControlName="preClosurePenalApplicable">
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
</mat-checkbox>
@if (closureSettingsForm.value.preClosurePenalApplicable) {
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Penal Interest' | translate }} (%)</mat-label>
<input type="number" matInput formControlName="preClosurePenalInterest" min="0" step="0.01" />
</mat-form-field>
<mat-form-field class="flex-48">
<mat-label>{{ 'labels.inputs.Period' | translate }}</mat-label>
<mat-select formControlName="preClosurePenalInterestOnTypeId">
@for (preClosurePenalInterestOnType of preClosurePenalInterestOnTypeData; track preClosurePenalInterestOnType) {
<mat-option [value]="preClosurePenalInterestOnType.id">
{{ preClosurePenalInterestOnType.value | translateKey: 'catalogs' }}
</mat-option>
}
</mat-select>
</mat-form-field>
}
</form>
@@ -0,0 +1,152 @@
/**
* 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/.
*/
// ── Transfer-to-Savings account card (mirrors the Deposit Account Link card) ──
.dal-card {
margin-top: 16px;
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #fff;
overflow: hidden;
&__header {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 24px;
border-bottom: 1px solid #f1f5f9;
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%);
}
&__header-left {
display: flex;
align-items: center;
gap: 14px;
}
&__icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: #0ea5e9;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
}
&__title {
margin: 0;
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
&__sub {
display: block;
font-size: 12px;
color: #64748b;
margin-top: 2px;
}
&__body {
padding: 20px 24px;
}
}
.dal-selected {
display: flex;
align-items: center;
gap: 16px;
padding: 16px 20px;
border: 2px solid #0ea5e9;
border-radius: 12px;
background: #f0f9ff;
&__info {
display: flex;
align-items: center;
gap: 14px;
flex: 1;
flex-wrap: wrap;
}
&__details {
display: flex;
flex-direction: column;
gap: 2px;
flex: 1;
}
&__accno {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
font-family: monospace;
}
&__product {
font-size: 12px;
color: #64748b;
}
&__balance {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
&__bal-label {
font-size: 10px;
color: #94a3b8;
text-transform: uppercase;
letter-spacing: 0.5px;
}
&__bal-amount {
font-size: 15px;
font-weight: 700;
color: #0c4a6e;
}
}
.dal-change-btn {
white-space: nowrap;
}
.dal-type-badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 12px;
border-radius: 999px;
font-size: 12px;
font-weight: 700;
white-space: nowrap;
background: #f1f5f9;
color: #475569;
border: 1px solid #e2e8f0;
mat-icon {
font-size: 14px;
width: 14px;
height: 14px;
}
&--savings {
background: #eff6ff;
color: #1d4ed8;
border-color: #bfdbfe;
mat-icon {
color: #2563eb;
}
}
}
@@ -0,0 +1,116 @@
/**
* 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, Input, OnChanges, SimpleChanges, inject } from '@angular/core';
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
* Recurring Deposit Account Maturity Terms Step -- what happens to this account at maturity
* (Withdraw / Transfer to Savings / Reinvest) and the pre-mature-closure penalty, decided
* per account rather than silently inherited from the product.
*/
@Component({
selector: 'mifosx-recurring-deposits-account-maturity-terms-step',
templateUrl: './recurring-deposits-account-maturity-terms-step.component.html',
styleUrls: ['./recurring-deposits-account-maturity-terms-step.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
MatIcon
]
})
export class RecurringDepositsAccountMaturityTermsStepComponent implements OnChanges {
private formBuilder = inject(UntypedFormBuilder);
@Input() recurringDepositsAccountTemplate: any;
@Input() recurringDepositsAccountProductTemplate: any;
/** Account already linked elsewhere in the wizard (the Details step's Deposit Account Link) --
* pre-fills Transfer to Savings so the officer doesn't have to pick the same account twice. */
@Input() linkedSavingsAccountId: number | null = null;
closureSettingsForm: UntypedFormGroup;
maturityInstructionOptions: any[] = [];
savingsAccountsData: any[] = [];
preClosurePenalInterestOnTypeData: any[] = [];
constructor() {
this.closureSettingsForm = this.formBuilder.group({
maturityInstructionId: [''],
preClosurePenalApplicable: [false],
preClosurePenalInterest: [
'',
Validators.min(0)
],
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
// 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
// Reinvest options (300/400) open a brand-new RD instead, so they ignore this field too.
if (value === 200) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
);
} else {
this.closureSettingsForm.removeControl('transferToSavingsId');
}
});
}
ngOnChanges(changes: SimpleChanges) {
if (changes.linkedSavingsAccountId) {
const control = this.closureSettingsForm.get('transferToSavingsId');
if (control && !control.value && this.linkedSavingsAccountId) {
control.setValue(this.linkedSavingsAccountId);
}
}
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
if (template) {
// Withdraw Deposit (id 100) books the maturity proceeds out of the bank's cash/vault the moment
// the account matures, before the customer has actually collected it -- disabled everywhere.
this.maturityInstructionOptions = (template.maturityInstructionOptions ?? []).filter(
(option: any) => option.id !== 100
);
this.savingsAccountsData = template.savingsAccounts ?? [];
this.preClosurePenalInterestOnTypeData = template.preClosurePenalInterestOnTypeOptions ?? [];
if (!this.closureSettingsForm.get('preClosurePenalApplicable').dirty) {
this.closureSettingsForm.patchValue({
preClosurePenalApplicable: template.preClosurePenalApplicable ?? false,
preClosurePenalInterest: template.preClosurePenalInterest ?? '',
preClosurePenalInterestOnTypeId: template.preClosurePenalInterestOnType?.id ?? ''
});
}
}
}
/** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
/** Chosen maturity/closure settings, ready to merge into the submit payload. */
get closureSettings() {
const settings = this.closureSettingsForm.getRawValue();
for (const key in settings) {
if (settings[key] === '') {
delete settings[key];
}
}
return settings;
}
}
@@ -298,10 +298,16 @@
<span class="preview-value">{{ recurringDepositsAccountProductTemplate.accountType }}</span>
</div>
}
@if (recurringDepositsAccountProductTemplate?.customerSegment) {
<!-- Prefer what the details step actually selected (derived from the client's legal form) over
the product's default, which is only the starting suggestion. -->
@if (
recurringDepositAccountData?.customerSegment || recurringDepositsAccountProductTemplate?.customerSegment
) {
<div class="preview-field">
<span class="preview-label">Customer Segment</span>
<span class="preview-value">{{ recurringDepositsAccountProductTemplate.customerSegment }}</span>
<span class="preview-value">{{
recurringDepositAccountData?.customerSegment ?? recurringDepositsAccountProductTemplate.customerSegment
}}</span>
</div>
}
@if (recurringDepositsAccountProductTemplate?.productCode) {
@@ -853,6 +859,41 @@
}
</div>
<div class="preview-section">
<div class="preview-section__header">
<i class="fas fa-flag-checkered preview-section__icon"></i>
<span class="preview-section__title">Maturity &amp; Pre-mature Closure</span>
</div>
<div class="preview-body">
<div class="preview-grid preview-grid--three">
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Maturity Instructions' | translate }}</span>
<span class="preview-value">{{ closureMaturityInstructionLabel }}</span>
</div>
@if (closureSettings?.transferToSavingsId) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Transfer to savings account' | translate }}</span>
<span class="preview-value">{{ closureTransferToSavingsLabel }}</span>
</div>
}
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalApplicable | yesNo }}</span>
</div>
@if (closureSettings?.preClosurePenalApplicable) {
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Penal Interest' | translate }} (%)</span>
<span class="preview-value">{{ closureSettings?.preClosurePenalInterest }}%</span>
</div>
<div class="preview-field">
<span class="preview-label">{{ 'labels.inputs.Period' | translate }}</span>
<span class="preview-value">{{ closurePenalInterestOnTypeLabel }}</span>
</div>
}
</div>
</div>
</div>
@if (!hideButtons) {
<div class="layout-row responsive-column align-center gap-2px margin-t">
<button mat-raised-button matStepperPrevious>
@@ -201,7 +201,29 @@ export class RecurringDepositsAccountPreviewStepComponent implements OnChanges {
/** Expand Chart Slab Index used in the view */
expandChartSlabIndex: number;
constructor() {}
/** Maturity/pre-mature-closure choices made in the Terms step -- shown here read-only. */
@Input() closureSettings: any = {};
get closureMaturityInstructionLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.maturityInstructionOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.maturityInstructionId);
return match?.value ?? '—';
}
get closureTransferToSavingsLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const accounts = template?.savingsAccounts ?? [];
const match = accounts.find((a: any) => a.id === this.closureSettings?.transferToSavingsId);
return match ? `(${match.accountNo}) ${match.savingsProductName}` : '—';
}
get closurePenalInterestOnTypeLabel(): string {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
const options = template?.preClosurePenalInterestOnTypeOptions ?? [];
const match = options.find((o: any) => o.id === this.closureSettings?.preClosurePenalInterestOnTypeId);
return match?.value ?? '—';
}
ngOnChanges() {
const template = this.recurringDepositsAccountProductTemplate || this.recurringDepositsAccountTemplate;
@@ -17,6 +17,7 @@ import {
} from '@angular/forms';
import { SettingsService } from 'app/settings/settings.service';
import { Currency } from 'app/shared/models/general.model';
import { OptionData } from 'app/shared/models/option-data.model';
import { MatCheckbox } from '@angular/material/checkbox';
import { InputAmountComponent } from '../../../../shared/input-amount/input-amount.component';
import { MatDivider } from '@angular/material/divider';
@@ -61,6 +62,10 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
periodFrequencyTypeData: any[] = [];
/** Preclosure Penal Interest Type on Data */
preClosurePenalInterestOnTypeData: any[] = [];
/** Options for what happens to the account at maturity */
maturityInstructionOptions: OptionData[];
/** Savings accounts eligible as a maturity-proceeds transfer target */
savingsAccountsData: any;
/** Tax Group */
taxGroup: any;
currency: Currency | null = null;
@@ -91,6 +96,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
: '',
maturityInstructions: recurringDepositsAccount.maturityInstructions ?? false,
creditMaturityProceeds: recurringDepositsAccount.creditMaturityProceeds ?? false,
maturityInstructionId: recurringDepositsAccount.maturityInstructionId ?? '',
penaltyGracePeriodMissedInstallments: recurringDepositsAccount.penaltyGracePeriodMissedInstallments ?? false,
panelRequestApplicable: recurringDepositsAccount.panelRequestApplicable ?? false,
noPenaltyNomineeHeirClosure: recurringDepositsAccount.noPenaltyNomineeHeirClosure ?? false,
@@ -182,6 +188,7 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
lockinPeriodFrequencyType: [''],
maturityInstructions: [false],
creditMaturityProceeds: [false],
maturityInstructionId: [''],
penaltyGracePeriodMissedInstallments: [false],
panelRequestApplicable: [false],
noPenaltyNomineeHeirClosure: [false],
@@ -217,6 +224,8 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.lockinPeriodFrequencyTypeData = recurringDepositsAccount.lockinPeriodFrequencyTypeOptions;
this.periodFrequencyTypeData = recurringDepositsAccount.periodFrequencyTypeOptions;
this.preClosurePenalInterestOnTypeData = recurringDepositsAccount.preClosurePenalInterestOnTypeOptions;
this.maturityInstructionOptions = recurringDepositsAccount.maturityInstructionOptions;
this.savingsAccountsData = recurringDepositsAccount.savingsAccounts;
}
buildDependencies() {
@@ -233,6 +242,16 @@ export class RecurringDepositsAccountSettingsStepComponent implements OnInit, On
this.recurringDepositAccountSettingsForm.addControl('recurringFrequencyType', new UntypedFormControl(''));
}
});
this.recurringDepositAccountSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
if (value > 100) {
this.recurringDepositAccountSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl('', Validators.required)
);
} else {
this.recurringDepositAccountSettingsForm.removeControl('transferToSavingsId');
}
});
}
get recurringDepositAccountSettings() {
@@ -87,6 +87,19 @@
<!-- Action menu (kebab) -->
<div class="rd-hero__actions">
@if (
!recurringDepositsAccountData.status.rejected &&
!recurringDepositsAccountData.status.submittedAndPendingApproval
) {
<button
mat-icon-button
matTooltip="Print Certificate"
(click)="printCertificate()"
aria-label="Print certificate"
>
<fa-icon icon="print"></fa-icon>
</button>
}
<button mat-icon-button [matMenuTriggerFor]="accountMenu" aria-label="Account actions" yPosition="below">
<fa-icon icon="ellipsis-v"></fa-icon>
</button>
@@ -22,6 +22,7 @@ import { RecurringDepositsButtonsConfiguration } from './recurring-deposits-butt
import { DeleteDialogComponent } from 'app/shared/delete-dialog/delete-dialog.component';
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
import { RecurringDepositConfirmationDialogComponent } from './custom-dialogs/recurring-deposit-confirmation-dialog/recurring-deposit-confirmation-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
import { Currency } from 'app/shared/models/general.model';
import { TranslateService } from '@ngx-translate/core';
import { AccountsService } from 'app/accounts/accounts.service';
@@ -426,6 +427,20 @@ export class RecurringDepositsAccountViewComponent implements OnInit {
});
}
/**
* Opens a printable deposit certificate for this account.
*/
printCertificate() {
this.dialog.open(DepositCertificateDialogComponent, {
width: '900px',
data: {
accountType: 'RD',
accountData: this.recurringDepositsAccountData,
clientId: this.recurringDepositsAccountData.clientId
}
});
}
/**
* Disables withhold tax for recurring deposits account
* Recurring deposits endpoint is not supported so using Savings endpoint.