diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index 910651bb5..7f1cab462 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -56,6 +56,12 @@ import {
ContactOtpBlockComponent,
ContactOtpState
} from 'app/clients/edit-client/contact-otp-block/contact-otp-block.component';
+import {
+ MINOR_AGE_THRESHOLD,
+ SENIOR_CITIZEN_AGE_THRESHOLD,
+ isMinorAge,
+ isSeniorCitizenAge
+} from 'app/clients/shared/client-age.util';
/**
* Create Client Component
@@ -299,10 +305,13 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
];
/** Calculated age detail string (Y/M/D) */
calculatedAgeDetail: string | null = null;
- /** Whether client is a minor (age < 18) */
+ /** Whether client is a minor — threshold from the shared age util, derived from Date of Birth */
isMinor = false;
- /** Whether client is a senior citizen (age >= 60) — auto-detected from Date of Birth */
+ /** Whether client is a senior citizen — threshold from the shared age util, derived from DOB */
isSeniorCitizen = false;
+ /** Exposed for template hints so the displayed rule can never drift from the applied rule. */
+ readonly minorAgeThreshold = MINOR_AGE_THRESHOLD;
+ readonly seniorCitizenAgeThreshold = SENIOR_CITIZEN_AGE_THRESHOLD;
/** Age breakdown for DB storage */
ageBreakdown = { years: 0, months: 0, days: 0 };
@@ -1237,8 +1246,8 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.calculatedAgeDetail = `${years} yr${years !== 1 ? 's' : ''} ${months} mo${months !== 1 ? 's' : ''} ${days} day${days !== 1 ? 's' : ''}`;
this.ageBreakdown = { years, months, days };
const wasMinor = this.isMinor;
- this.isMinor = years < 18;
- this.isSeniorCitizen = years >= 60;
+ this.isMinor = isMinorAge(years);
+ this.isSeniorCitizen = isSeniorCitizenAge(years);
// Add/remove guardian form group based on minor status
if (this.isMinor && !wasMinor) {
this.createClientForm.addControl(
diff --git a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html
index 564f7f25b..f290c2be2 100644
--- a/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html
+++ b/src/app/clients/client-stepper/client-preview-step/client-preview-step.component.html
@@ -195,7 +195,7 @@
client.isMinor ? 'preview-badge preview-badge--danger' : 'preview-badge preview-badge--verified'
"
>
- {{ client.isMinor ? 'Yes (Age < 18)' : 'No' }}
+ {{ client.isMinor ? 'Yes' : 'No' }}
diff --git a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
index 287371407..02ce124f1 100644
--- a/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
+++ b/src/app/clients/clients-view/personal-data-tab/personal-data-tab.component.html
@@ -105,7 +105,7 @@
[class.pd-verify-badge--verified]="!clientViewData.isMinor"
[class.pd-verify-badge--pending]="clientViewData.isMinor"
>
- {{ clientViewData.isMinor ? 'Yes (Age < 18)' : 'No' }}
+ {{ clientViewData.isMinor ? 'Yes' : 'No' }}
diff --git a/src/app/clients/shared/client-age.util.ts b/src/app/clients/shared/client-age.util.ts
new file mode 100644
index 000000000..9f2931f06
--- /dev/null
+++ b/src/app/clients/shared/client-age.util.ts
@@ -0,0 +1,97 @@
+/**
+ * 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/.
+ */
+
+/**
+ * Single source of truth for every age-derived client classification.
+ *
+ * These rules were previously re-implemented as bare literals in ten separate components (the client
+ * stepper plus each product's account-details step), which is how a client could end up classified one
+ * way on the client screen and another way on the account screen -- and, in the FD case, how a
+ * 24-year-old ended up with a senior-citizen interest rate. Anything that needs to know whether a
+ * client is a minor or a senior citizen must derive it from here rather than comparing ages inline.
+ */
+
+/** Below this age a client is a minor: guardian mapping required, restricted KYC/OVD document set. */
+export const MINOR_AGE_THRESHOLD = 10;
+
+/** At or above this age a client qualifies for senior-citizen benefits (higher deposit rates). */
+export const SENIOR_CITIZEN_AGE_THRESHOLD = 60;
+
+/**
+ * Age at which a minor account must be converted to a regular one. Deliberately independent of
+ * {@link MINOR_AGE_THRESHOLD}: legal majority in India is 18 regardless of the age at which this CBS
+ * chooses to stop applying minor-specific onboarding rules.
+ */
+export const AGE_OF_MAJORITY = 18;
+
+/** Parses the backend's date-of-birth representation -- either a `[yyyy, m, d]` array or a date string. */
+export function parseClientDob(dob: any): Date | null {
+ if (!dob) return null;
+ if (Array.isArray(dob) && dob.length >= 3) {
+ return new Date(dob[0], dob[1] - 1, dob[2]);
+ }
+ const parsed = new Date(dob);
+ return isNaN(parsed.getTime()) ? null : parsed;
+}
+
+/**
+ * Completed years between `dob` and `asOf`. Pass the tenant's business date as `asOf` wherever it is
+ * available, so a back-dated or forward-dated business date classifies the client the same way the
+ * rest of the system does.
+ */
+export function calculateAge(dob: Date, asOf?: Date | null): number {
+ const today = asOf ?? new Date();
+ let age = today.getFullYear() - dob.getFullYear();
+ const monthDiff = today.getMonth() - dob.getMonth();
+ if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < dob.getDate())) {
+ age--;
+ }
+ return age;
+}
+
+/** True when the age qualifies as a minor. `null` (unknown DOB) is never a minor. */
+export function isMinorAge(age: number | null | undefined): boolean {
+ return age !== null && age !== undefined && age < MINOR_AGE_THRESHOLD;
+}
+
+/** True when the age qualifies for senior-citizen benefits. `null` (unknown DOB) never qualifies. */
+export function isSeniorCitizenAge(age: number | null | undefined): boolean {
+ return age !== null && age !== undefined && age >= SENIOR_CITIZEN_AGE_THRESHOLD;
+}
+
+/**
+ * Whether a client record represents a natural person. Only a natural person can be a minor or a
+ * senior citizen -- an entity/company has no age, so age-derived benefits must never apply to one
+ * even if some incorporation date happens to be stored against it.
+ */
+export function isIndividualClient(client: any): boolean {
+ const legalForm = client?.legalForm;
+ if (!legalForm) return true; // absent legal form: treat as individual, matching the backend default
+ const code = String(legalForm.code ?? '').toLowerCase();
+ if (code) return code.includes('individual') && !code.includes('nonindividual');
+ const value = String(legalForm.value ?? legalForm.name ?? '').toLowerCase();
+ return value === 'individual';
+}
+
+/**
+ * The senior-citizen decision for a whole client record: individual, known DOB, and old enough.
+ * Returns the derived age alongside the verdict so callers can show "why" without recomputing it.
+ */
+export function deriveSeniorCitizenStatus(
+ client: any,
+ asOf?: Date | null
+): { isSeniorCitizen: boolean; age: number | null; isIndividual: boolean } {
+ const isIndividual = isIndividualClient(client);
+ const dob = parseClientDob(client?.dateOfBirth);
+ const age = dob ? calculateAge(dob, asOf) : null;
+ return {
+ isSeniorCitizen: isIndividual && isSeniorCitizenAge(age),
+ age,
+ isIndividual
+ };
+}
diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html
index 1cf4ac389..69528ae98 100644
--- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html
+++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.html
@@ -529,7 +529,7 @@
Age
- Must be below 18 years
+ Must be below {{ minorAgeThreshold }} years
diff --git a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts
index 8c5592d1a..770117e66 100644
--- a/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts
+++ b/src/app/current-accounts/current-account-stepper/current-account-details-step/current-account-details-step.component.ts
@@ -15,6 +15,12 @@ import { Dates } from 'app/core/utils/dates';
import { CurrentAccountsService } from 'app/current-accounts/current-accounts.service';
import { SettingsService } from 'app/settings/settings.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 { MatDialog } from '@angular/material/dialog';
import { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { MatIcon } from '@angular/material/icon';
@@ -99,6 +105,8 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
// ── Client info (auto-fetched) ─────────────────────────────────────────
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 = '';
@@ -444,10 +452,10 @@ export class CurrentAccountDetailsStepComponent 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';
@@ -458,15 +466,15 @@ export class CurrentAccountDetailsStepComponent implements OnInit {
isTileDisabled(value: string): boolean {
if (this.readOnly) return true;
- 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 === 'SENIOR_CITIZEN' && this.clientAge !== null && this.clientAge < 60)
+ if (value === 'MINOR' && !isMinorAge(this.clientAge))
+ return `Age ${this.clientAge} yrs — Minor only allowed below years`;
+ 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';
if (value === 'SENIOR_CITIZEN' && this.clientAge === null) return 'DOB not available — cannot verify age';
@@ -609,7 +617,7 @@ export class CurrentAccountDetailsStepComponent 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') || '';
}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html
index b5f865cbd..07482706b 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.html
@@ -618,7 +618,7 @@
Age
- Must be below 18 years
+ Must be below {{ minorAgeThreshold }} years
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts
index 7aa178b82..146e5b877 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-details-step/fixed-deposit-account-details-step.component.ts
@@ -26,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';
@@ -107,8 +113,16 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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 = '';
@@ -279,7 +293,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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) {
@@ -359,6 +375,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
this.autoDetectAccountStatus();
}
}
+ this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null;
+ this.applyInferredCustomerSegment();
this.cdr.detectChanges();
});
}
@@ -386,8 +404,8 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
}
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;
}
@@ -412,10 +430,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
}
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';
@@ -428,10 +446,10 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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';
@@ -481,6 +499,9 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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;
@@ -591,6 +612,24 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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;
@@ -733,7 +772,7 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
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') || '';
}
@@ -797,6 +836,15 @@ export class FixedDepositAccountDetailsStepComponent implements OnInit, OnChange
}
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;
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html
index 51bf1b47f..8eab0d0bc 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.html
@@ -170,12 +170,12 @@
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts
index a54cf9e1d..beff3f51d 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-interest-rate-chart-step/fixed-deposit-account-interest-rate-chart-step.component.ts
@@ -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();
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html
new file mode 100644
index 000000000..de34802f2
--- /dev/null
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/interest-schedule-dialog/interest-schedule-dialog.component.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+
+ {{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}
+ {{ paidCount }} of {{ schedule.length }} periods paid so far
+
+
+
+
+
+
+ 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.
+
+}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss
index f1b16485e..a205c1c24 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.scss
@@ -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;
+ }
+}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts
index 7da833264..083888a2f 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/general-tab/general-tab.component.ts
@@ -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;
@@ -69,4 +72,58 @@ export class GeneralTabComponent {
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;
+ }
}
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html
index 702defd3a..5fd042184 100644
--- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html
+++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.html
@@ -613,7 +613,7 @@
Age
- Must be below 18 years
+ Must be below {{ minorAgeThreshold }} years
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts
index f7bb9d68f..18035cd4b 100644
--- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts
+++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-details-step/recurring-deposits-account-details-step.component.ts
@@ -26,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';
@@ -97,8 +103,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
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 = '';
@@ -337,6 +351,8 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
this.autoDetectAccountStatus();
}
}
+ this.clientLegalForm = client?.legalForm?.value ?? client?.legalForm?.name ?? null;
+ this.applyInferredCustomerSegment();
this.cdr.detectChanges();
});
}
@@ -364,16 +380,16 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
}
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';
@@ -386,10 +402,10 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
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';
@@ -441,6 +457,9 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
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;
@@ -557,6 +576,24 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
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;
@@ -695,7 +732,7 @@ export class RecurringDepositsAccountDetailsStepComponent implements OnInit, OnC
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') || '';
}
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html
index 7fa51c3e8..237abd8dc 100644
--- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html
+++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.html
@@ -110,12 +110,12 @@
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts
index 46cf19aa6..554d9d1ee 100644
--- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts
+++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-interest-rate-chart-step/recurring-deposits-account-interest-rate-chart-step.component.ts
@@ -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();
diff --git a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html
index 4abb78a1a..0a12ed363 100644
--- a/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html
+++ b/src/app/deposits/recurring-deposits/recurring-deposits-account-stepper/recurring-deposits-account-preview-step/recurring-deposits-account-preview-step.component.html
@@ -298,10 +298,16 @@
{{ recurringDepositsAccountProductTemplate.accountType }}
}
- @if (recurringDepositsAccountProductTemplate?.customerSegment) {
+
+ @if (
+ recurringDepositAccountData?.customerSegment || recurringDepositsAccountProductTemplate?.customerSegment
+ ) {
Deposit Account Link
+ @if (fixedDepositProductDetailsForm.get('fdType')?.value === 'SPECIAL') {
+
+ Required for FD Special -- every account will need a linked savings account so its monthly interest, and its
+ principal at maturity, can be paid out automatically.
+
+ }
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss
index 2e487792e..6ed6d51eb 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.scss
@@ -76,6 +76,16 @@ $gray-900: #202124;
.fdpd-checkbox-row {
display: flex;
- align-items: center;
+ flex-direction: column;
+ align-items: flex-start;
+ justify-content: center;
+ gap: 4px;
min-height: 56px;
}
+
+.fdpd-hint {
+ font-size: 12px;
+ color: $gray-900;
+ opacity: 0.65;
+ margin-left: 32px;
+}
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts
index 8df6052c6..7ee45d938 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-details-step/fixed-deposit-product-details-step.component.ts
@@ -43,6 +43,24 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange
constructor() {
this.createFixedDepositProductDetailsForm();
+ // FD Special is a non-cumulative, monthly-interest-payout product: the backend requires a linked
+ // savings account for every SPECIAL-type FD account (it's where the monthly interest and, at
+ // maturity, the principal both get transferred to automatically), so force this on here rather than
+ // let an admin publish a SPECIAL product that later fails every account creation for missing config.
+ this.fixedDepositProductDetailsForm
+ .get('fdType')
+ ?.valueChanges.subscribe((fdType: string) => this.applyFdTypeConstraints(fdType));
+ }
+
+ private applyFdTypeConstraints(fdType: string | null) {
+ const linkCtrl = this.fixedDepositProductDetailsForm.get('shareLinkingEnabled');
+ if (!linkCtrl) return;
+ if (fdType === 'SPECIAL') {
+ linkCtrl.setValue(true);
+ linkCtrl.disable();
+ } else {
+ linkCtrl.enable();
+ }
}
ngOnInit() {
@@ -124,10 +142,11 @@ export class FixedDepositProductDetailsStepComponent implements OnInit, OnChange
endDate: this.parseDate(template.endDate),
shareLinkingEnabled: !!template.shareLinkingEnabled
});
+ this.applyFdTypeConstraints(this.fixedDepositProductDetailsForm.get('fdType')?.value ?? null);
}
get fixedDepositProductDetails() {
- const formValue = { ...this.fixedDepositProductDetailsForm.value };
+ const formValue = { ...this.fixedDepositProductDetailsForm.getRawValue() };
const dateFormat = this.settingsService.dateFormat;
if (formValue.effectiveTo instanceof Date) {
formValue.effectiveTo = this.dateUtils.formatDate(formValue.effectiveTo, dateFormat) || '';
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
index efe589523..11061126a 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
@@ -114,21 +114,37 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
this.applyFdTypeConstraints();
}
+ /**
+ * FD Special is a non-cumulative, monthly-interest-payout product -- the backend forces both the
+ * posting AND compounding period to Monthly for any account opened under a SPECIAL-type FD product
+ * (see DepositAccountAssembler#assembleFrom on the backend), so the product itself is locked to the
+ * same values here to avoid an admin configuring a combination that the backend will silently override
+ * at account-creation time.
+ */
private applyFdTypeConstraints() {
- if (!this.fixedDepositProductTermsForm || !this.interestPostingPeriodTypeData) return;
+ if (!this.fixedDepositProductTermsForm) return;
const postingCtrl = this.fixedDepositProductTermsForm.get('interestPostingPeriodType');
- if (!postingCtrl) return;
+ const compoundingCtrl = this.fixedDepositProductTermsForm.get('interestCompoundingPeriodType');
if (this.fdType === 'SPECIAL') {
- // Monthly posting = id 4 (MONTHLY in Fineract enum)
- const monthlyOption = this.interestPostingPeriodTypeData?.find(
- (o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4
- );
- if (monthlyOption) {
- postingCtrl.setValue(monthlyOption.id);
- postingCtrl.disable();
+ // Monthly = id 4 (MONTHLY in Fineract's SavingsPostingInterestPeriodType/SavingsCompoundingInterestPeriodType enums)
+ const findMonthly = (data: any) => data?.find((o: any) => o.value?.toLowerCase() === 'monthly' || o.id === 4);
+ if (postingCtrl && this.interestPostingPeriodTypeData) {
+ const monthlyPosting = findMonthly(this.interestPostingPeriodTypeData);
+ if (monthlyPosting) {
+ postingCtrl.setValue(monthlyPosting.id);
+ postingCtrl.disable();
+ }
+ }
+ if (compoundingCtrl && this.interestCompoundingPeriodTypeData) {
+ const monthlyCompounding = findMonthly(this.interestCompoundingPeriodTypeData);
+ if (monthlyCompounding) {
+ compoundingCtrl.setValue(monthlyCompounding.id);
+ compoundingCtrl.disable();
+ }
}
} else {
- postingCtrl.enable();
+ postingCtrl?.enable();
+ compoundingCtrl?.enable();
}
}
diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html
index 1f8ce8870..31b97c866 100644
--- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html
+++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.html
@@ -491,7 +491,7 @@
Age
- Must be below 18 years
+ Must be below {{ minorAgeThreshold }} years
diff --git a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts
index cb93fd19e..ebdea0abe 100644
--- a/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts
+++ b/src/app/savings/savings-account-stepper/savings-account-details-step/savings-account-details-step.component.ts
@@ -16,6 +16,12 @@ import { MatDialog } from '@angular/material/dialog';
/** Custom Services */
import { SavingsService } from 'app/savings/savings.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 { MatStepperPrevious, MatStepperNext } from '@angular/material/stepper';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -80,6 +86,8 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
// ── Client info (auto-fetched) ─────────────────────────────────────────
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 = '';
@@ -282,16 +290,16 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
isTileDisabled(value: string): boolean {
if (this.readOnly) return true;
- 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';
@@ -306,10 +314,10 @@ export class SavingsAccountDetailsStepComponent implements OnInit {
}
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';
@@ -597,7 +605,7 @@ export class SavingsAccountDetailsStepComponent 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') || '';
}
diff --git a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts
index a368f824d..6be1eca86 100644
--- a/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts
+++ b/src/app/savings/savings-account-view/passbook-tab/passbook-tab.component.ts
@@ -84,7 +84,10 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
this.accountBalance = account.summary?.accountBalance || 0;
this.availableBalance = account.summary?.availableBalance || 0;
this.activatedOnDate = account.timeline?.activatedOnDate || null;
- this.allTransactions = this.recalculateRunningBalances(this.sortAscending(account.transactions || []));
+ const nonReversedTransactions = (account.transactions || []).filter(
+ (txn: SavingsAccountTransaction) => !txn.reversed
+ );
+ this.allTransactions = this.recalculateRunningBalances(this.sortAscending(nonReversedTransactions));
this.filteredTransactions = [...this.allTransactions];
});
@@ -182,9 +185,12 @@ export class PassbookTabComponent implements OnInit, OnDestroy {
private recalculateRunningBalances(transactions: SavingsAccountTransaction[]): SavingsAccountTransaction[] {
let balance = 0;
return transactions.map((txn) => {
- // Accruals and on-hold entries don't affect actual account balance
+ // Accruals, on-hold entries, and reversed transactions don't affect actual account balance
const affectsBalance =
- !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease;
+ !txn.reversed &&
+ !txn.transactionType.accrual &&
+ !txn.transactionType.amountHold &&
+ !txn.transactionType.amountRelease;
if (affectsBalance) {
if (this.isDebit(txn.transactionType)) {
diff --git a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts
index 2b79720cd..f15cc9f5a 100644
--- a/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts
+++ b/src/app/savings/savings-account-view/transactions-tab/transactions-tab.component.ts
@@ -132,8 +132,8 @@ export class TransactionsTabComponent implements OnInit {
ngOnInit() {
this.hideAccrualsParam = new UntypedFormControl(false);
- this.hideReversedParam = new UntypedFormControl(false);
- this.setTransactions();
+ this.hideReversedParam = new UntypedFormControl(true);
+ this.filterTransactions(this.hideReversedParam.value, this.hideAccrualsParam.value);
}
setTransactions(): void {
@@ -222,6 +222,7 @@ export class TransactionsTabComponent implements OnInit {
});
}
this.dataSource = new MatTableDataSource(transactions);
+ this.accountWithTransactions = this.transactionsData && this.transactionsData.length > 0;
this.dataSource.paginator = this.paginator;
this.dataSource.sort = this.sort;
}
@@ -263,7 +264,10 @@ export class TransactionsTabComponent implements OnInit {
let balance = 0;
return transactions.map((txn) => {
const affectsBalance =
- !txn.transactionType.accrual && !txn.transactionType.amountHold && !txn.transactionType.amountRelease;
+ !txn.reversed &&
+ !txn.transactionType.accrual &&
+ !txn.transactionType.amountHold &&
+ !txn.transactionType.amountRelease;
if (affectsBalance) {
if (this.isDebit(txn.transactionType)) {
balance -= txn.amount;