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
@@ -394,7 +394,7 @@
}
</div>
<!-- Minor / Guardian Mapping (mandatory if age < 18) -->
<!-- Minor / Guardian Mapping (mandatory for a minor -- see MINOR_AGE_THRESHOLD) -->
@if (isMinor && createClientForm.get('legalFormId')?.value === LegalFormId.PERSON) {
<div class="guardian-section">
<!-- Header bar -->
@@ -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(
@@ -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' }}
</span>
</span>
</div>
@@ -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' }}
</span>
</div>
</div>
+97
View File
@@ -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
};
}