feat(clients,deposits): auto-derive senior-citizen/customer-segment, fix disbursement display and reversed-transaction balances
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:
+1
-1
@@ -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">
|
||||
|
||||
+45
-8
@@ -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') || '';
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -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>
|
||||
|
||||
|
||||
+73
-5
@@ -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();
|
||||
|
||||
+8
-2
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user