/** * 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 = { 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 = { 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 = { 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); }