Files
cbsnexora/src/app/fd-dashboard/fd-dashboard.compute.ts
T
niteshandClaude Sonnet 5 865ad8ba88 feat(fd): add FD Dashboard -- maturity ladder + manual Renew action
New screen (Deposits menu, repurposing the dead "FD Maturity Report" shell button): every
active Fixed Deposit positioned against its own maturity date (Matured / Due Soon / Upcoming /
On Track), computed client-side from maturityDate so it can never drift out of step with the
account. Reads the new lean maturity-dashboard backend endpoint.

A matured, renewable row (no maturity instruction chosen at account-opening, or an explicit
Reinvest instruction still waiting on its product's grace period) gets a Renew action wired to
the account's own Payment dialog -- see the FD account-view commit for that side of it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-24 16:01:43 +05:30

241 lines
9.5 KiB
TypeScript

/**
* 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/.
*/
/**
* Where an active Fixed Deposit sits relative to its own maturity date.
*
* MATURED accounts are the ones staff actually need to act on -- the FD has run its course but is
* still ACTIVE in the core, meaning nobody has applied the maturity instruction yet (or the
* scheduled job hasn't reached it). DUE_SOON and UPCOMING exist so a branch can see what's coming
* before it lands, not just after.
*/
export type FdMaturityStage = 'MATURED' | 'DUE_SOON' | 'UPCOMING' | 'ON_TRACK';
/** Day-count thresholds, measured forward from today toward the maturity date. */
export const FD_MATURITY_POLICY = {
/** Maturing within this many days counts as DUE_SOON. */
dueSoonDays: 7,
/** Maturing within this many days (and not already DUE_SOON) counts as UPCOMING. */
upcomingDays: 30
};
export interface FdMaturityStageMeta {
stage: FdMaturityStage;
label: string;
/** Colour key the template maps to a badge/tile class. */
tone: 'info' | 'warn' | 'danger' | 'neutral';
summary: string;
}
export const FD_MATURITY_STAGES: Record<FdMaturityStage, FdMaturityStageMeta> = {
MATURED: {
stage: 'MATURED',
label: 'Matured',
tone: 'danger',
summary: 'Past maturity date and still active -- the maturity instruction has not been applied yet.'
},
DUE_SOON: {
stage: 'DUE_SOON',
label: `Maturing ≤ ${FD_MATURITY_POLICY.dueSoonDays} Days`,
tone: 'warn',
summary: 'Matures within the week.'
},
UPCOMING: {
stage: 'UPCOMING',
label: `Maturing ≤ ${FD_MATURITY_POLICY.upcomingDays} Days`,
tone: 'info',
summary: 'Matures within the month.'
},
ON_TRACK: {
stage: 'ON_TRACK',
label: 'On Track',
tone: 'neutral',
summary: 'Maturity date is further out.'
}
};
/** FD types the manual-renewal action applies to. SPECIAL is architecturally forced to Transfer to
* Savings server-side and can never be Reinvest-configured, so it's never renewable here. */
const RENEWABLE_FD_TYPES = [
'SINGLE',
'DOUBLE'
];
/** DepositAccountOnClosureType labels, for the "what will happen automatically" sub-label shown on a
* matured row that already carries an explicit instruction from account-opening. */
const ON_CLOSURE_TYPE_LABELS: Record<number, string> = {
200: 'Transfer to Savings',
300: 'Reinvest Principal + Interest',
400: 'Reinvest Principal Only'
};
/** One active Fixed Deposit account, positioned on the maturity ladder. */
export interface FdMaturityRow {
id: number;
accountNo: string;
clientId: number | null;
clientName: string;
productName: string;
maturityDate: string;
depositAmount: number | null;
maturityAmount: number | null;
/** Negative once the maturity date has passed. */
daysRemaining: number;
/** 0 while not yet matured; how many days past maturity otherwise. */
daysOverdue: number;
stage: FdMaturityStage;
meta: FdMaturityStageMeta;
/** Null/0 -- no buffer configured; renewing after the maturity date itself pays gap interest. */
renewalGracePeriodDays: number | null;
linkedAccountId: number | null;
/** The account's own stored DepositAccountOnClosureType, raw -- null when none was chosen at
* account-opening. On a renewable row that DOES carry one (grace-period case: the account picked
* Reinvest, and is only waiting because the product also has a grace window), the Renew dialog
* pre-selects this rather than presenting a neutral, undecided choice -- the maker already decided
* what this FD should do, grace only changed when that gets carried out. */
onClosureType: number | null;
/** True only when the account was opened with NO maturity instruction at all (the officer left
* Maturity Instructions blank at account-opening) -- that's the one case nothing else will ever
* act on this FD, so the Dashboard's manual Renew action is meaningful. An account that WAS given
* an explicit instruction (Transfer to Savings or either Reinvest option) auto-processes on its
* own via the nightly job, exactly as it always has -- the Dashboard only reports on those, it
* doesn't also offer to renew them. */
isReinvestEligible: boolean;
/** True once matured AND eligible -- the only case where nothing has auto-processed it, so a
* manual action from the Dashboard is meaningful. */
renewable: boolean;
/** Only meaningful when renewable -- true if still inside the grace window (backdated renewal,
* no gap interest), false if the window has passed (renews from today, gap interest applies). */
withinGraceWindow: boolean;
/** Set on a matured row that is NOT renewable because it already carries an explicit instruction --
* what it was configured to do at account-opening, shown so staff can see at a glance what tonight's
* job will do without opening the account. Null for a renewable row (nothing decided yet) or a row
* that isn't matured. */
configuredInstructionLabel: string | null;
}
/** Days between two ISO dates, positive when `later` is after `earlier`. */
function daysBetween(earlier: string, later: string): number {
const a = new Date(`${earlier}T00:00:00Z`).getTime();
const b = new Date(`${later}T00:00:00Z`).getTime();
return Math.round((b - a) / 86400000);
}
/** Which rung of the ladder an account is on, from how many days remain until maturity. */
export function maturityStage(daysRemaining: number): FdMaturityStage {
if (daysRemaining <= 0) {
return 'MATURED';
}
if (daysRemaining <= FD_MATURITY_POLICY.dueSoonDays) {
return 'DUE_SOON';
}
if (daysRemaining <= FD_MATURITY_POLICY.upcomingDays) {
return 'UPCOMING';
}
return 'ON_TRACK';
}
/** Builds one dashboard row from a raw `/fixeddepositaccounts` list item and today's ISO date. */
export function buildMaturityRow(
fd: {
id: number;
accountNo: string;
clientId?: number;
clientName?: string;
savingsProductName?: string;
productName?: string;
fdType?: string;
maturityDate: string;
depositAmount?: number;
maturityAmount?: number;
onClosureType?: number;
linkedAccountId?: number;
renewalGracePeriodDays?: number;
},
today: string
): FdMaturityRow | null {
if (!fd.maturityDate) {
// No maturity date means the account isn't fully assembled yet (e.g. a chart not applied) --
// nothing sound to bucket it by, so it's left off the worklist rather than shown wrong.
return null;
}
const daysRemaining = daysBetween(today, fd.maturityDate);
const stage = maturityStage(daysRemaining);
// No grace configured reads as a 0-day buffer, not "not renewable" -- it only shrinks the backdate
// window, matching the backend's default (renewMaturedFixedDeposit treats a missing value as 0).
const gracePeriodDays = fd.renewalGracePeriodDays ?? 0;
// Mirrors updateMaturityDetails()'s own "will I auto-process this?" check exactly, inverted: the
// nightly job leaves an account alone (never touches it) only when it was opened with NO maturity
// instruction at all -- that's the one case the Dashboard's Renew action exists for. An account
// opened WITH an explicit instruction auto-processes tonight regardless of the product's renewal
// grace period (the grace window never delays it), so it's never renewable here either way.
const isReinvestEligible =
!!fd.fdType && RENEWABLE_FD_TYPES.includes(fd.fdType as string) && fd.onClosureType == null;
const renewable = stage === 'MATURED' && isReinvestEligible;
const withinGraceWindow = renewable && daysOverdueFor(daysRemaining) <= gracePeriodDays;
const configuredInstructionLabel =
stage === 'MATURED' && !renewable && fd.onClosureType != null
? (ON_CLOSURE_TYPE_LABELS[fd.onClosureType] ?? null)
: null;
return {
id: fd.id,
accountNo: fd.accountNo || '',
clientId: fd.clientId ?? null,
clientName: fd.clientName || '—',
productName: fd.productName || fd.savingsProductName || '—',
maturityDate: fd.maturityDate,
depositAmount: fd.depositAmount ?? null,
maturityAmount: fd.maturityAmount ?? null,
daysRemaining,
daysOverdue: daysOverdueFor(daysRemaining),
stage,
meta: FD_MATURITY_STAGES[stage],
renewalGracePeriodDays: gracePeriodDays,
linkedAccountId: fd.linkedAccountId ?? null,
onClosureType: fd.onClosureType ?? null,
isReinvestEligible,
renewable,
withinGraceWindow,
configuredInstructionLabel
};
}
function daysOverdueFor(daysRemaining: number): number {
return daysRemaining < 0 ? -daysRemaining : 0;
}
export interface FdMaturitySummary {
total: number;
matured: number;
dueSoon: number;
upcoming: number;
onTrack: number;
}
export function maturitySummary(rows: FdMaturityRow[]): FdMaturitySummary {
return {
total: rows.length,
matured: rows.filter((r) => r.stage === 'MATURED').length,
dueSoon: rows.filter((r) => r.stage === 'DUE_SOON').length,
upcoming: rows.filter((r) => r.stage === 'UPCOMING').length,
onTrack: rows.filter((r) => r.stage === 'ON_TRACK').length
};
}
/** Sorts the worklist so the most overdue, then the soonest-to-mature, rows come first. */
export function sortMaturityRows(rows: FdMaturityRow[]): FdMaturityRow[] {
const rank: Record<FdMaturityStage, number> = {
MATURED: 0,
DUE_SOON: 1,
UPCOMING: 2,
ON_TRACK: 3
};
return [...rows].sort((a, b) => rank[a.stage] - rank[b.stage] || a.daysRemaining - b.daysRemaining);
}