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:
@@ -1401,7 +1401,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
dbCr: 'Cr',
|
||||
accountNo: cr.acNo,
|
||||
accountName: this.rowAccountNames[cr.acNo] ?? cr.acNo,
|
||||
accountType: cr.acTyp === 'CA' || cr.acTyp === 'CB' ? 'CURRENT' : cr.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||||
accountType: this.creditEntryAccountType(cr.acTyp),
|
||||
savingsAccountId: cr.acTyp !== 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||||
glAccountId: cr.acTyp === 'GL' ? (this.rowAccountIds[cr.acNo] ?? null) : null,
|
||||
amount: String(cr.amount),
|
||||
@@ -1421,7 +1421,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
dbCr: r.dbCr,
|
||||
accountNo: r.acNo,
|
||||
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||||
accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : r.acTyp === 'GL' ? 'GL' : 'SAVINGS',
|
||||
accountType: this.creditEntryAccountType(r.acTyp),
|
||||
savingsAccountId: r.acTyp !== 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||||
glAccountId: r.acTyp === 'GL' ? (this.rowAccountIds[r.acNo] ?? null) : null,
|
||||
amount: String(r.amount),
|
||||
@@ -1490,7 +1490,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
dbCr,
|
||||
accountNo: r.acNo,
|
||||
accountName: this.rowAccountNames[r.acNo] ?? r.acNo,
|
||||
accountType: r.acTyp === 'CA' || r.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||
accountType: this.creditEntryAccountType(r.acTyp),
|
||||
savingsAccountId: this.rowAccountIds[r.acNo],
|
||||
amount: String(r.amount),
|
||||
description: r.description ?? null
|
||||
@@ -1937,6 +1937,36 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
/** Maps UI account type → { listEndpoint, detailEndpoint, nomineeType, searchResource } */
|
||||
/**
|
||||
* The account-type label carried on a disbursement request's credit/debit entry.
|
||||
*
|
||||
* The backend (ClerkDisbursementRequestApiResource) only ever branches on 'GL' and 'CURRENT'; every
|
||||
* other value takes the same path, and in the disbursement flow it resolves current-vs-savings from
|
||||
* the database rather than from this string. So naming a fixed or recurring deposit accurately here
|
||||
* changes only what the approving checker sees, never what gets posted. Previously anything that
|
||||
* wasn't current or GL was labelled 'SAVINGS', which showed a checker an FD as a savings account --
|
||||
* exactly the wrong information to approve a disbursement against.
|
||||
*/
|
||||
private creditEntryAccountType(acTyp: string | undefined | null): string {
|
||||
switch ((acTyp ?? '').toUpperCase()) {
|
||||
case 'GL':
|
||||
return 'GL';
|
||||
case 'CA':
|
||||
case 'CB':
|
||||
return 'CURRENT';
|
||||
case 'FD':
|
||||
return 'FIXED_DEPOSIT';
|
||||
case 'RD':
|
||||
return 'RECURRING_DEPOSIT';
|
||||
case 'SHR':
|
||||
return 'SHARE';
|
||||
case 'LA':
|
||||
return 'LOAN';
|
||||
default:
|
||||
return 'SAVINGS';
|
||||
}
|
||||
}
|
||||
|
||||
private accountTypeConfig(acTyp: string): {
|
||||
list: string;
|
||||
detail: string;
|
||||
@@ -2136,8 +2166,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
// Track per-row account id/name for multi-row disbursement
|
||||
if (this.selectedRow?.acNo) {
|
||||
this.rowAccountIds[this.selectedRow.acNo] = acc.id;
|
||||
// clientName must be in this chain: a deposit account's detail response carries the holder
|
||||
// under clientName, not name/displayName (only GL accounts have `name`). Without it every FD
|
||||
// and RD fell through to accountNo, so a disbursement request showed the checker an account
|
||||
// number where the account holder's name belongs.
|
||||
this.rowAccountNames[this.selectedRow.acNo] =
|
||||
acc.name ?? acc.displayName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
acc.name ?? acc.displayName ?? acc.clientName ?? acc.glAccountName ?? acc.accountNo ?? '';
|
||||
this.rowClientIds[this.selectedRow.acNo] = acc.clientId ?? null;
|
||||
}
|
||||
if (this.selectedRow) {
|
||||
@@ -3696,7 +3730,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
|
||||
return {
|
||||
accountNo: this.accountDetails.accountNo,
|
||||
accountType: 'SAVINGS',
|
||||
accountType: this.creditEntryAccountType(row.acTyp),
|
||||
savingsAccountId: this.accountDetails.id,
|
||||
clientId: this.accountDetails.clientId ?? this.clientDetails?.id ?? null,
|
||||
clientName: this.clientDetails?.displayName ?? this.accountDetails?.clientName ?? null,
|
||||
@@ -3805,7 +3839,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
|
||||
const isGl = row.acTyp === 'GL';
|
||||
return {
|
||||
accountNo: isGl ? (this.accountDetails.glCode ?? this.accountDetails.name) : this.accountDetails.accountNo,
|
||||
accountType: isGl ? 'GL' : row.acTyp === 'CA' || row.acTyp === 'CB' ? 'CURRENT' : 'SAVINGS',
|
||||
accountType: this.creditEntryAccountType(row.acTyp),
|
||||
savingsAccountId: isGl ? null : this.accountDetails.id,
|
||||
// GL-specific fields — ignored by backend for non-GL types
|
||||
glAccountId: isGl ? this.accountDetails.id : null,
|
||||
|
||||
@@ -159,7 +159,7 @@
|
||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||
<td>{{ entry.accountName }}</td>
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@@ -197,7 +197,7 @@
|
||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||
<td>{{ entry.accountName }}</td>
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
@@ -233,7 +233,7 @@
|
||||
<td class="cell-mono">{{ entry.accountNo }}</td>
|
||||
<td>{{ entry.accountName }}</td>
|
||||
<td>
|
||||
<span class="dri-type-badge">{{ entry.accountType }}</span>
|
||||
<span class="dri-type-badge">{{ accountTypeLabel(entry.accountType) }}</span>
|
||||
</td>
|
||||
<td class="num dri-cr-amount">₹{{ entry.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -143,6 +143,24 @@ export class DisbursementReviewComponent implements OnInit {
|
||||
return this.drEntries.reduce((sum, e) => sum + (e.amount ?? 0), 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable account type for the badge. The stored value is the backend's own code
|
||||
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL), so this only
|
||||
* changes presentation -- a checker should read "Fixed Deposit", not "FIXED_DEPOSIT".
|
||||
*/
|
||||
accountTypeLabel(accountType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
SAVINGS: 'Savings',
|
||||
CURRENT: 'Current',
|
||||
FIXED_DEPOSIT: 'Fixed Deposit',
|
||||
RECURRING_DEPOSIT: 'Recurring Deposit',
|
||||
SHARE: 'Share',
|
||||
LOAN: 'Loan',
|
||||
GL: 'GL'
|
||||
};
|
||||
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'dri-status--pending',
|
||||
|
||||
@@ -170,7 +170,9 @@
|
||||
<td class="cell-mono">{{ ce.accountNo || '—' }}</td>
|
||||
<td>{{ ce.accountName || '—' }}</td>
|
||||
<td>
|
||||
<span class="mdr-actype">{{ ce.accountType || '—' }}</span>
|
||||
<span class="mdr-actype">{{
|
||||
ce.accountType ? accountTypeLabel(ce.accountType) : '—'
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="num mdr-ce-amount">{{ ce.amount | number: '1.2-2' }}</td>
|
||||
</tr>
|
||||
|
||||
@@ -187,6 +187,23 @@ export class MyDisbursementRequestsComponent implements OnInit {
|
||||
return this.getCreditEntries(req).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable account type for the badge. The stored value is the backend's own code
|
||||
* (SAVINGS / CURRENT / FIXED_DEPOSIT / RECURRING_DEPOSIT / SHARE / LOAN / GL); presentation only.
|
||||
*/
|
||||
accountTypeLabel(accountType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
SAVINGS: 'Savings',
|
||||
CURRENT: 'Current',
|
||||
FIXED_DEPOSIT: 'Fixed Deposit',
|
||||
RECURRING_DEPOSIT: 'Recurring Deposit',
|
||||
SHARE: 'Share',
|
||||
LOAN: 'Loan',
|
||||
GL: 'GL'
|
||||
};
|
||||
return labels[(accountType ?? '').toUpperCase()] ?? accountType;
|
||||
}
|
||||
|
||||
statusClass(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PENDING: 'mdr-status--pending',
|
||||
|
||||
+1
-1
@@ -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 -->
|
||||
|
||||
+13
-4
@@ -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(
|
||||
|
||||
+1
-1
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
+1
-1
@@ -529,7 +529,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">
|
||||
|
||||
+16
-8
@@ -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') || '';
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -618,7 +618,7 @@
|
||||
<mat-form-field appearance="outline" class="soa-field">
|
||||
<mat-label>Age</mat-label>
|
||||
<input matInput [value]="clientAge !== null ? clientAge + ' years' : '—'" readonly />
|
||||
<mat-hint>Must be below 18 years</mat-hint>
|
||||
<mat-hint>Must be below {{ minorAgeThreshold }} years</mat-hint>
|
||||
</mat-form-field>
|
||||
|
||||
<mat-form-field appearance="outline" class="soa-field">
|
||||
|
||||
+57
-9
@@ -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;
|
||||
|
||||
+2
-2
@@ -170,12 +170,12 @@
|
||||
<!-- Senior Citizen -->
|
||||
<div class="fdrc-calc-field fdrc-calc-field--senior">
|
||||
<label class="fdrc-senior-label">
|
||||
<input type="checkbox" [(ngModel)]="isSeniorCitizen" (change)="onSeniorCitizenChange()" />
|
||||
<input type="checkbox" [ngModel]="isSeniorCitizen" [disabled]="true" />
|
||||
<span class="fdrc-senior-text">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
Senior Citizen
|
||||
</span>
|
||||
<span class="fdrc-senior-hint">Higher rate applied if available</span>
|
||||
<span class="fdrc-senior-hint">{{ seniorCitizenHint }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
|
||||
+75
-5
@@ -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();
|
||||
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<!--
|
||||
Copyright since 2025 Mifos Initiative
|
||||
|
||||
This Source Code Form is subject to the terms of the Mozilla Public
|
||||
License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
-->
|
||||
|
||||
<div class="isd-dialog">
|
||||
<h2 mat-dialog-title class="isd-header">
|
||||
<span class="isd-header__icon"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||
<span class="isd-header__text">
|
||||
<span class="isd-header__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</span>
|
||||
<span class="isd-header__sub">{{ paidCount }} of {{ schedule.length }} periods paid so far</span>
|
||||
</span>
|
||||
</h2>
|
||||
|
||||
<div mat-dialog-content class="isd-content">
|
||||
<p class="isd-note">
|
||||
<fa-icon icon="info-circle" class="m-r-5"></fa-icon>
|
||||
Computed by the actual interest engine for this account's full term -- the exact amount posted each month can vary
|
||||
slightly with the number of days in that month.
|
||||
</p>
|
||||
|
||||
<div class="isd-scroll">
|
||||
<table class="isd-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ 'labels.inputs.From Date' | translate }}</th>
|
||||
<th>{{ 'labels.inputs.To Date' | translate }}</th>
|
||||
<th class="isd-table__amount-col">{{ 'labels.inputs.Interest Amount' | translate }}</th>
|
||||
<th class="isd-table__status-col">{{ 'labels.inputs.Status' | translate }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (period of schedule; track $index) {
|
||||
<tr [class.isd-table__row--paid]="period.paid">
|
||||
<td>{{ period.fromDate | dateFormat }}</td>
|
||||
<td>{{ period.toDate | dateFormat }}</td>
|
||||
<td class="isd-table__amount-col">
|
||||
{{ period.interestAmount | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td class="isd-table__status-col">
|
||||
@if (period.paid) {
|
||||
<span class="isd-badge isd-badge--paid">
|
||||
<fa-icon icon="check-circle" class="m-r-5"></fa-icon>Paid
|
||||
</span>
|
||||
} @else {
|
||||
<span class="isd-badge isd-badge--pending">Pending</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="2">{{ 'labels.inputs.Total Interest Payable' | translate }}</td>
|
||||
<td class="isd-table__amount-col">
|
||||
{{ totalInterest | currency: currencyCode : 'symbol-narrow' : '1.2-2' }}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div mat-dialog-actions align="end" class="isd-actions">
|
||||
<button mat-button mat-dialog-close>{{ 'labels.buttons.Close' | translate }}</button>
|
||||
</div>
|
||||
</div>
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
$navy: #0d1b2a;
|
||||
$slate: #475569;
|
||||
$muted: #64748b;
|
||||
$border: #e2e8f0;
|
||||
$soft: #f8fafc;
|
||||
$green: #047857;
|
||||
$green-bg: #ecfdf5;
|
||||
$amber: #92400e;
|
||||
$amber-bg: #fffbeb;
|
||||
|
||||
.isd-dialog {
|
||||
min-width: 560px;
|
||||
max-width: 720px;
|
||||
}
|
||||
|
||||
.isd-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0;
|
||||
|
||||
&__icon {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 8px;
|
||||
background: linear-gradient(135deg, #3b82f6 0%, #1e40af 100%);
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
&__title {
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
}
|
||||
|
||||
&__sub {
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
}
|
||||
}
|
||||
|
||||
.isd-content {
|
||||
max-height: 60vh;
|
||||
}
|
||||
|
||||
.isd-note {
|
||||
margin: 4px 0 12px;
|
||||
font-size: 12px;
|
||||
color: $muted;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.isd-scroll {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.isd-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 8px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px dashed #eef2f7;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
th {
|
||||
color: $slate;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
background: $soft;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
tbody tr:hover {
|
||||
background: $soft;
|
||||
}
|
||||
|
||||
&__row--paid {
|
||||
background: rgb(4 120 87 / 4%);
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: 700;
|
||||
color: $navy;
|
||||
border-bottom: none;
|
||||
border-top: 2px solid $border;
|
||||
}
|
||||
|
||||
&__amount-col {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
&__status-col {
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.isd-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 3px 10px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
|
||||
&--paid {
|
||||
background: $green-bg;
|
||||
color: $green;
|
||||
}
|
||||
|
||||
&--pending {
|
||||
background: $amber-bg;
|
||||
color: $amber;
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright since 2025 Mifos Initiative
|
||||
*
|
||||
* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import {
|
||||
MAT_DIALOG_DATA,
|
||||
MatDialogRef,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose
|
||||
} from '@angular/material/dialog';
|
||||
import { MatButton } from '@angular/material/button';
|
||||
import { CurrencyPipe } from '@angular/common';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { DateFormatPipe } from '../../../../../pipes/date-format.pipe';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
/**
|
||||
* FD Special's full month-wise interest schedule, in a popup rather than always inline on General --
|
||||
* a 24+ month term makes for a long table that would otherwise push every other card on the tab down
|
||||
* the page. Each row also shows whether that period's interest has actually posted yet, not just what
|
||||
* the engine projects it to be.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'mifosx-interest-schedule-dialog',
|
||||
templateUrl: './interest-schedule-dialog.component.html',
|
||||
styleUrls: ['./interest-schedule-dialog.component.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
MatDialogTitle,
|
||||
MatDialogContent,
|
||||
MatDialogActions,
|
||||
MatDialogClose,
|
||||
MatButton,
|
||||
FaIconComponent,
|
||||
CurrencyPipe,
|
||||
DateFormatPipe
|
||||
]
|
||||
})
|
||||
export class InterestScheduleDialogComponent {
|
||||
dialogRef = inject<MatDialogRef<InterestScheduleDialogComponent>>(MatDialogRef);
|
||||
data: {
|
||||
schedule: Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }>;
|
||||
currencyCode: string;
|
||||
} = inject(MAT_DIALOG_DATA);
|
||||
|
||||
get schedule(): Array<{ fromDate: any; toDate: any; interestAmount: number; paid: boolean }> {
|
||||
return this.data.schedule ?? [];
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
return this.data.currencyCode;
|
||||
}
|
||||
|
||||
get totalInterest(): number {
|
||||
return this.schedule.reduce((sum, row) => sum + (row.interestAmount ?? 0), 0);
|
||||
}
|
||||
|
||||
get paidCount(): number {
|
||||
return this.schedule.filter((row) => row.paid).length;
|
||||
}
|
||||
}
|
||||
+78
-3
@@ -167,11 +167,36 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
<span class="nx-row__label">
|
||||
{{
|
||||
(isSpecialFixedDeposit
|
||||
? 'labels.inputs.Principal Returned at Maturity'
|
||||
: 'labels.inputs.Maturity Amount'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<span class="nx-row__value nx-row__value--amount" [class.nx-row__value--positive]="!isSpecialFixedDeposit">
|
||||
{{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (isSpecialFixedDeposit && nextInterestPayout !== null) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Next Interest Payout' | translate }}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
{{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpecialFixedDeposit && totalValueAtMaturity !== null) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{
|
||||
'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate
|
||||
}}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
{{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (fixedDepositsAccountData.summary.totalDeposits) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Total Deposits' | translate }}</span>
|
||||
@@ -221,13 +246,63 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Maturity Amount' | translate }}</span>
|
||||
<span class="nx-row__label">
|
||||
{{
|
||||
(isSpecialFixedDeposit
|
||||
? 'labels.inputs.Principal Returned at Maturity'
|
||||
: 'labels.inputs.Maturity Amount'
|
||||
) | translate
|
||||
}}
|
||||
</span>
|
||||
<span class="nx-row__value nx-row__value--amount">
|
||||
{{ fixedDepositsAccountData.maturityAmount | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
@if (isSpecialFixedDeposit && nextInterestPayout !== null) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Next Interest Payout' | translate }}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
{{ nextInterestPayout | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
@if (isSpecialFixedDeposit && totalValueAtMaturity !== null) {
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{
|
||||
'labels.inputs.Total Value at Maturity (Principal + Interest)' | translate
|
||||
}}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
{{ totalValueAtMaturity | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isSpecialFixedDeposit && projectedInterestSchedule.length) {
|
||||
<div class="nx-card gt-schedule">
|
||||
<div class="nx-card__head">
|
||||
<span class="nx-card__ico"><fa-icon icon="calendar-alt"></fa-icon></span>
|
||||
<h3 class="nx-card__title">{{ 'labels.heading.Monthly Interest Payout Schedule' | translate }}</h3>
|
||||
</div>
|
||||
<div class="nx-card__body gt-schedule__body">
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Total Interest Payable' | translate }}</span>
|
||||
<span class="nx-row__value nx-row__value--amount nx-row__value--positive">
|
||||
{{ totalProjectedInterest | currency: currency.code : 'symbol-narrow' : '1.2-2' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="nx-row">
|
||||
<span class="nx-row__label">{{ 'labels.inputs.Periods Paid So Far' | translate }}</span>
|
||||
<span class="nx-row__value">{{ paidPeriodsCount }} / {{ projectedInterestSchedule.length }}</span>
|
||||
</div>
|
||||
<button mat-stroked-button color="primary" class="gt-schedule__view-btn" (click)="openInterestSchedule()">
|
||||
<fa-icon icon="table" class="m-r-5"></fa-icon>
|
||||
{{ 'labels.buttons.View Full Schedule' | translate }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
+10
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+57
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
+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) {
|
||||
|
||||
+6
@@ -116,6 +116,12 @@
|
||||
|
||||
<div class="fdpd-field fdpd-field--full fdpd-checkbox-row">
|
||||
<mat-checkbox formControlName="shareLinkingEnabled">Deposit Account Link</mat-checkbox>
|
||||
@if (fixedDepositProductDetailsForm.get('fdType')?.value === 'SPECIAL') {
|
||||
<span class="fdpd-hint">
|
||||
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.
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+11
-1
@@ -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;
|
||||
}
|
||||
|
||||
+20
-1
@@ -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) || '';
|
||||
|
||||
+26
-10
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -491,7 +491,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">
|
||||
|
||||
+16
-8
@@ -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') || '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user