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>
241 lines
9.5 KiB
TypeScript
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);
|
|
}
|