Merge branch 'dev' of https://git.lincpay.tech/CBSNexora/cbsnexora into dev
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