feat(clients,deposits): auto-derive senior-citizen/customer-segment, fix disbursement display and reversed-transaction balances
Tests / testRigor (push) Canceled after 0s
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s

Four independent, self-contained changes bundled from this session's work:

1. Auto-derive Senior Citizen / Minor / Customer Segment instead of manual entry

   Senior Citizen was a manual, staff-editable checkbox on FD/RD account
   creation with no validation against the client's actual age -- this let
   non-senior clients get the senior-citizen interest rate by mistake (found
   twice on live test accounts, where m_savings_account.is_senior_citizen
   was true despite the client being confirmed non-senior at the client
   record level). Likewise "minor" client classification was hardcoded to
   the age-18 threshold with no single source of truth, and FD/RD account
   creation's "Customer Segment" (Individual / Non-Individual) had no
   connection to the Individual/Non-Individual Legal Form already captured
   at client creation.

   - New src/app/clients/shared/client-age.util.ts centralises age/DOB
     derivation: MINOR_AGE_THRESHOLD (now 10, was 18), SENIOR_CITIZEN_AGE_
     THRESHOLD (60), AGE_OF_MAJORITY (18, kept separate from the minor
     threshold -- majority date math is unrelated to the minor/adult
     classification threshold), calculateAge/isMinorAge/isSeniorCitizenAge/
     isIndividualClient/deriveSeniorCitizenStatus.
   - FD/RD interest-rate-chart steps: Senior Citizen checkbox is now derived
     from the client's DOB (age >= 60, individual clients only) and
     disabled for manual editing; a hint explains why it is checked/
     unchecked.
   - Client general step and all four account-details-steps (savings,
     current, FD, RD): replaced hardcoded 18/60 literals with the shared
     helpers; "Must be below 18" hints now read the actual threshold.
   - FD/RD account-details-steps: Customer Segment auto-selects to match
     the client's Legal Form (exact match, not substring -- a substring
     match would misclassify "Individual" as matching "Non-Individual").
   - RD preview step: fixed a pre-existing display bug that always showed
     the product's default Customer Segment instead of what was actually
     selected on the account.

2. Fix wrong Account Name / Account Type on Disbursement Review screens

   Clerk disbursement review and "My Disbursement Requests" showed the
   account NUMBER instead of the holder's name, and always labelled the
   account type "SAVINGS" regardless of the real account type (FD/RD/
   Share/Loan), because transaction-entry.component.ts's account-type
   mapping only checked for CA/CB and its account-name fallback chain
   never tried clientName. Added creditEntryAccountType() covering
   GL/CA/CB/FD/RD/SHR/LA, and accountTypeLabel() display helpers on both
   review screens.

3. Stop reversed transactions from distorting displayed running balances

   Passbook and Transactions tabs were including reversed transactions in
   their client-side running-balance recalculation, so a reversed posting
   (visible struck-through) still shifted the balance shown for every
   transaction after it. Both now filter out txn.reversed before computing
   running balances; Transactions tab also defaults "Hide Reversed" to on.

4. New Interest Schedule dialog on the FD account view

   Added custom-dialogs/interest-schedule-dialog, opened from the FD
   General tab, showing the account's period-by-period interest schedule.

Excludes src/environments/.env.ts (auto-generated build version/hash
stamp, regenerated on every build -- not meaningful to hand-commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 18:58:20 +05:30
co-authored by Claude Sonnet 5
parent 1273bdbe0e
commit 395fb8ace8
35 changed files with 997 additions and 91 deletions
@@ -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">
@@ -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') || '';
}
@@ -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();
@@ -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) {