feat: cash-only registers, day book sections and DD amendments
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-09-03 18:26:22 +05:30
parent 6442562df0
commit fbce73fd17
111 changed files with 9076 additions and 3507 deletions
@@ -0,0 +1,398 @@
<!--
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="dr">
<!-- Header -->
<div class="dr-head">
<div>
<h2 class="dr-head-t"><fa-icon icon="clock"></fa-icon> Inactive, Dormant &amp; DEAF Reports</h2>
<div class="dr-head-s">
Accounts with no customer-induced transaction, at each of the three marks RBI sets. Interest the bank posts does
not reset the clock — only a deposit or withdrawal the customer makes.
</div>
</div>
<div class="dr-head-a">
<button class="dr-btn dr-btn--slim" (click)="load()" [disabled]="loading">
<fa-icon icon="undo"></fa-icon> Reload
</button>
<button class="dr-btn dr-btn--primary" (click)="exportExcel()" [disabled]="loading">
<fa-icon icon="file-excel"></fa-icon> Export all three
</button>
</div>
</div>
@if (errorMessage) {
<div class="dr-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>{{ errorMessage }}</div>
</div>
}
<!-- The money that has left customers' accounts but not reached RBI -->
@if (summary.pendingRemittanceCount > 0) {
<div class="dr-alert">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>₹{{ summary.pendingRemittance | number: '1.0-0' }}</strong> from
<strong>{{ summary.pendingRemittanceCount }}</strong> escheated
{{ summary.pendingRemittanceCount === 1 ? 'account has' : 'accounts have' }} not been recorded as remitted to
RBI's DEAF. The balance has already left the customer's account, so until the remittance is recorded the money
sits with the bank against no fund.
<button class="dr-link" (click)="showTab('ESCHEAT'); pendingRemittanceOnly = true">Show these accounts</button>
</div>
</div>
}
<!-- Entry: filters that apply to all three reports -->
<div class="dr-entry">
<div class="dr-entry-h"><fa-icon icon="filter"></fa-icon> Report criteria</div>
<div class="dr-entry-b">
<div class="dr-field">
<label>As on</label>
<!-- The idle period is worked out server-side against the business date, so this states the
day the figures are for rather than offering to recompute them for another. -->
<input class="dr-inp dr-inp--ro" type="date" [value]="today" disabled />
</div>
<div class="dr-field">
<label>Branch</label>
<select class="dr-inp" [(ngModel)]="branchCode">
<option value="">All branches</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</div>
<div class="dr-field">
<label>Minimum balance</label>
<input class="dr-inp" type="number" [(ngModel)]="minBalance" placeholder="Any" min="0" />
</div>
<div class="dr-field dr-field--wide">
<label>Search</label>
<input class="dr-inp" type="text" [(ngModel)]="search" placeholder="Customer name, ID or account number" />
</div>
<div class="dr-field dr-field--btn">
<button class="dr-btn dr-btn--slim" (click)="resetFilters()"><fa-icon icon="undo"></fa-icon> Reset</button>
</div>
</div>
</div>
<!-- The three reports, side by side -->
<div class="dr-tabs">
@for (t of tabs; track t) {
<button
class="dr-tab"
[class]="'dr-tab dr-tab--' + stageMeta[t].tone"
[class.dr-tab--on]="tab === t"
(click)="showTab(t)"
>
<div class="dr-tab-h">
<span class="dr-tab-l">{{ stageMeta[t].label }}</span>
<span class="dr-tab-n">{{ countFor(t) }}</span>
</div>
<div class="dr-tab-d">After {{ stageMeta[t].afterDays }} days idle</div>
<div class="dr-tab-e">{{ stageMeta[t].effect }}</div>
</button>
}
</div>
<!-- What this stage means, and how an account comes back from it -->
<div class="dr-context">
<div class="dr-context-i">
<fa-icon icon="info-circle"></fa-icon>
<div>
<strong>{{ stageMeta[tab].label }}.</strong> {{ stageMeta[tab].effect }}
<div class="dr-context-r"><strong>Revival:</strong> {{ stageMeta[tab].revival }}</div>
</div>
</div>
<div class="dr-context-t">
<span class="dr-context-c">{{ visibleRows.length }}</span> accounts ·
<span class="dr-context-c">₹{{ visibleBalance | number: '1.0-0' }}</span> total balance
</div>
</div>
@if (tab === 'ESCHEAT') {
<div class="dr-bar">
<label class="dr-chk">
<input type="checkbox" [(ngModel)]="pendingRemittanceOnly" /> Not yet remitted to RBI
</label>
<span class="dr-money">
<span class="dr-money-i dr-money-i--bad">
₹{{ summary.pendingRemittance | number: '1.0-0' }} not remitted
</span>
<span class="dr-money-i dr-money-i--ok"> ₹{{ summary.remittedBalance | number: '1.0-0' }} with the fund </span>
@if (summary.claimedBackCount > 0) {
<span class="dr-money-i"> ₹{{ summary.claimedBackBalance | number: '1.0-0' }} claimed back </span>
}
</span>
<span class="dr-spacer"></span>
@if (selected.size > 0) {
<span class="dr-selinfo"> {{ selected.size }} selected · ₹{{ selectedTotal | number: '1.0-0' }} </span>
<button class="dr-btn dr-btn--slim" (click)="clearSelection()">Clear</button>
}
<button class="dr-btn dr-btn--primary" (click)="openRemit()" [disabled]="selected.size === 0">
<fa-icon icon="check"></fa-icon> Record remittance to RBI
</button>
</div>
}
<!-- Table -->
@if (loading) {
<div class="dr-empty"><fa-icon icon="spinner" [spin]="true"></fa-icon> Loading…</div>
} @else if (visibleRows.length === 0) {
<div class="dr-empty">No accounts at this stage match the criteria above.</div>
} @else {
<div class="dr-tablewrap">
<table class="dr-table">
<thead>
<tr>
@if (tab === 'ESCHEAT') {
<th class="dr-tick">
<input
type="checkbox"
[checked]="allSelected"
(change)="toggleSelectAll()"
title="Select every account not yet remitted"
/>
</th>
}
<th>Account No</th>
<th>Customer</th>
<th>Branch</th>
<th>Product</th>
<th class="num">{{ tab === 'ESCHEAT' ? 'Balance Escheated' : 'Balance' }}</th>
<th>Last Customer Txn</th>
<th>Idle For</th>
@if (tab === 'ESCHEAT') {
<th>Escheated On</th>
<th>Remittance to RBI</th>
} @else {
<th>Next Stage</th>
}
<th></th>
</tr>
</thead>
<tbody>
@for (r of visibleRows; track r.accountId) {
<tr [class.dr-row--warn]="isNearingEscheat(r)" [class.dr-row--sel]="selected.has(r.accountId)">
@if (tab === 'ESCHEAT') {
<td class="dr-tick">
<!-- An already-remitted balance cannot be remitted again. -->
<input
type="checkbox"
[checked]="selected.has(r.accountId)"
[disabled]="r.remittance === 'REMITTED'"
(click)="toggleSelect(r, $event)"
/>
</td>
}
<td class="mono">{{ r.accountNo }}</td>
<td>
<strong>{{ r.clientName }}</strong>
<div class="dr-sub mono">{{ r.clientId }} · {{ r.mobile }}</div>
</td>
<td>{{ branchName(r.branchCode) }}</td>
<td class="dr-prod">{{ r.productName }}</td>
<td class="num strong">{{ r.balance | number: '1.0-0' }}</td>
<td class="mono">{{ r.lastCustomerTxnDate }}</td>
<td>
<span [class]="toneClass(r.stage)">{{ idleText(r.daysIdle) }}</span>
</td>
@if (tab === 'ESCHEAT') {
<td class="mono">{{ r.escheatedOn || '—' }}</td>
<td>
<span [class]="remittanceClass(r.remittance)">
{{ r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted' }}
</span>
@if (r.remittedOn) {
<div class="dr-sub mono">{{ r.remittedOn }}</div>
}
@if (r.deafReference) {
<div class="dr-sub mono">{{ r.deafReference }}</div>
}
@if (r.refundedOn) {
<div class="dr-claimed"><fa-icon icon="undo"></fa-icon> Claimed back {{ r.refundedOn }}</div>
}
</td>
} @else {
<td>
<div class="dr-next">{{ r.nextStageLabel }}</div>
<div class="dr-sub mono" [class.dr-sub--red]="isNearingEscheat(r)">
{{ r.nextStageDate }}
@if (isNearingEscheat(r)) {
· {{ daysToNext(r) }} days
}
</div>
</td>
}
<td class="dr-act">
<button class="dr-mini" (click)="openClient(r, $event)">
<fa-icon icon="user"></fa-icon> Customer
</button>
<!-- A remitted balance can still be claimed, so long as it has not been already. -->
@if (tab === 'ESCHEAT' && r.remittance === 'REMITTED' && !r.refundedOn) {
<button
class="dr-mini"
title="The customer claimed this balance back — the bank paid them and recovers it from DEAF"
(click)="openRefund(r, $event)"
>
<fa-icon icon="undo"></fa-icon> Claimed back
</button>
}
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- Where the money in the escheat report has to go -->
@if (tab === 'ESCHEAT') {
<div class="dr-foot">
<fa-icon icon="gavel"></fa-icon>
<div>
<strong>Depositor Education and Awareness Fund.</strong> A balance unclaimed for ten years is transferred to
RBI's DEAF. The transfer itself is made outside this system, so an account shows as escheated here from the day
the balance leaves it — the remittance column records that the money actually reached RBI, and stays "not
remitted" until it does. A customer can still claim the amount at any time; the bank pays them and recovers it
from the fund, which shows here as claimed back.
</div>
</div>
}
</div>
<!-- Record that money was sent to RBI -->
@if (remitOpen) {
<div class="dr-modal-bg" (click)="remitOpen = false">
<div class="dr-modal" (click)="$event.stopPropagation()">
<div class="dr-modal-h">
<h3>Record remittance to RBI's DEAF</h3>
<button class="dr-x" (click)="remitOpen = false">&times;</button>
</div>
<div class="dr-modal-b">
<div class="dr-modal-sum">
<div>
<span class="dr-modal-n">{{ selected.size }}</span>
<span class="dr-modal-l">accounts</span>
</div>
<div>
<span class="dr-modal-n">₹{{ selectedTotal | number: '1.0-0' }}</span>
<span class="dr-modal-l">total remitted</span>
</div>
</div>
<div class="dr-note-box">
<fa-icon icon="info-circle"></fa-icon>
This records a transfer that has already been made to RBI. It does not move any money — it marks these
balances as accounted for, so they stop showing as outstanding.
</div>
<label class="dr-lbl">Date sent to RBI <span class="dr-req">*</span></label>
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="remitDate" />
<label class="dr-lbl">RBI / DEAF reference</label>
<input
class="dr-inp dr-inp--full"
type="text"
[(ngModel)]="remitReference"
placeholder="e.g. DEAF/2026/Q2/004913 — as printed on RBI's advice"
/>
<label class="dr-lbl">Remarks</label>
<textarea class="dr-ta" rows="3" [(ngModel)]="remitRemarks" placeholder="Optional"></textarea>
@if (remitError) {
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ remitError }}</div>
}
</div>
<div class="dr-modal-f">
<button class="dr-btn" (click)="remitOpen = false">Cancel</button>
<button class="dr-btn dr-btn--primary" (click)="saveRemittance()" [disabled]="remitSaving">
@if (remitSaving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
} @else {
Record remittance
}
</button>
</div>
</div>
</div>
}
<!-- Customer claimed a remitted balance back -->
@if (refundOpen && refundRow) {
<div class="dr-modal-bg" (click)="refundOpen = false">
<div class="dr-modal dr-modal--sm" (click)="$event.stopPropagation()">
<div class="dr-modal-h">
<h3>Record a claim against DEAF</h3>
<button class="dr-x" (click)="refundOpen = false">&times;</button>
</div>
<div class="dr-modal-b">
<div class="dr-modal-who">
<strong>{{ refundRow.clientName }}</strong>
<span class="mono">{{ refundRow.accountNo }}</span>
</div>
<div class="dr-modal-sum">
<div>
<span class="dr-modal-n">₹{{ refundRow.balance | number: '1.0-0' }}</span>
<span class="dr-modal-l">claimed</span>
</div>
<div>
<span class="dr-modal-n mono">{{ refundRow.remittedOn }}</span>
<span class="dr-modal-l">remitted to RBI</span>
</div>
</div>
<div class="dr-note-box">
<fa-icon icon="info-circle"></fa-icon>
A depositor can claim an unclaimed balance at any time, however long it has been with the fund. The bank pays
the customer and recovers the amount from RBI. This records that, so the money is no longer counted as sitting
with the fund.
</div>
<label class="dr-lbl">Date the customer was paid <span class="dr-req">*</span></label>
<input class="dr-inp dr-inp--full" type="date" [(ngModel)]="refundDate" />
@if (refundError) {
<div class="dr-err"><fa-icon icon="exclamation-triangle"></fa-icon> {{ refundError }}</div>
}
</div>
<div class="dr-modal-f">
<button class="dr-btn" (click)="refundOpen = false">Cancel</button>
<button class="dr-btn dr-btn--primary" (click)="saveRefund()" [disabled]="refundSaving">
@if (refundSaving) {
<fa-icon icon="spinner" [spin]="true"></fa-icon> Saving…
} @else {
Record claim
}
</button>
</div>
</div>
</div>
}
@if (toastVisible) {
<div class="dr-toast">{{ toastMsg }}</div>
}
@@ -0,0 +1,778 @@
/**
* 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/.
*/
:host {
--d-navy: #1a237e;
--d-blue: #1d4ed8;
--d-blue-l: #eff6ff;
--d-green: #15803d;
--d-green-l: #dcfce7;
--d-amber: #b45309;
--d-amber-l: #fef3c7;
--d-red: #c0392b;
--d-red-l: #fdecea;
--d-crit: #7f1d1d;
--d-crit-l: #fee2e2;
--d-g50: #f8fafc;
--d-g100: #f1f5f9;
--d-g200: #e2e8f0;
--d-g300: #cbd5e1;
--d-g500: #64748b;
--d-g700: #334155;
--d-g900: #0f172a;
display: block;
font-family: 'DM Sans', system-ui, sans-serif;
color: var(--d-g900);
background: var(--d-g50);
min-height: 100%;
padding: 16px;
}
.mono {
font-family: 'JetBrains Mono', ui-monospace, monospace;
font-size: 11.5px;
}
.num {
text-align: right;
}
.strong {
font-weight: 700;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.dr-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
}
.dr-head-t {
margin: 0;
font-size: 18px;
font-weight: 700;
color: var(--d-navy);
}
.dr-head-s {
margin-top: 3px;
font-size: 12px;
color: var(--d-g500);
max-width: 780px;
line-height: 1.5;
}
.dr-head-a {
display: flex;
gap: 8px;
flex-shrink: 0;
}
/* The unremitted-money warning: the one number on this screen that is a live exposure. */
.dr-alert {
display: flex;
gap: 10px;
padding: 11px 14px;
margin-bottom: 14px;
background: var(--d-red-l);
border: 1px solid #f5b7b1;
border-left: 4px solid var(--d-red);
border-radius: 7px;
font-size: 12px;
line-height: 1.55;
color: var(--d-g700);
fa-icon {
color: var(--d-red);
margin-top: 2px;
}
}
.dr-link {
display: inline;
margin-left: 4px;
padding: 0;
border: 0;
background: none;
font: inherit;
font-weight: 700;
color: var(--d-red);
text-decoration: underline;
cursor: pointer;
}
/* ── Entry / criteria ───────────────────────────────────────────────────── */
.dr-entry {
margin-bottom: 14px;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
overflow: hidden;
}
.dr-entry-h {
display: flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
background: var(--d-g100);
border-bottom: 1px solid var(--d-g200);
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--d-g500);
}
.dr-entry-b {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 12px;
padding: 13px 14px;
}
.dr-field {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
label {
font-size: 10.5px;
font-weight: 700;
color: var(--d-g500);
text-transform: uppercase;
letter-spacing: 0.3px;
}
&--wide {
flex: 1;
min-width: 240px;
}
&--btn {
min-width: 0;
}
}
.dr-inp {
padding: 7px 10px;
border: 1px solid var(--d-g300);
border-radius: 5px;
font: inherit;
font-size: 12px;
background: #fff;
}
.dr-chk {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 10px;
font-size: 12px;
color: var(--d-g700);
cursor: pointer;
}
/* ── The three reports, side by side ────────────────────────────────────── */
.dr-tabs {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(255px, 1fr));
gap: 10px;
margin-bottom: 12px;
}
.dr-tab {
text-align: left;
padding: 12px 14px;
border: 1px solid var(--d-g200);
border-top: 3px solid var(--d-g300);
border-radius: 7px;
background: #fff;
font: inherit;
cursor: pointer;
transition: box-shadow 0.12s;
&:hover {
box-shadow: 0 2px 8px rgb(15 23 42 / 9%);
}
&--info {
border-top-color: var(--d-blue);
}
&--warn {
border-top-color: var(--d-amber);
}
&--critical {
border-top-color: var(--d-crit);
}
/* The report on display stays obvious while its rows are being read. */
&--on {
background: var(--d-g100);
box-shadow: inset 0 0 0 1px var(--d-g300);
}
}
.dr-tab-h {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
}
.dr-tab-l {
font-size: 13px;
font-weight: 700;
}
.dr-tab-n {
font-size: 20px;
font-weight: 700;
line-height: 1;
}
.dr-tab-d {
margin-top: 2px;
font-size: 10.5px;
font-weight: 600;
color: var(--d-g500);
}
.dr-tab-e {
margin-top: 5px;
font-size: 10.5px;
color: var(--d-g500);
line-height: 1.45;
}
/* ── Context strip ──────────────────────────────────────────────────────── */
.dr-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 10px 14px;
margin-bottom: 10px;
background: var(--d-blue-l);
border: 1px solid #bfdbfe;
border-radius: 7px;
font-size: 11.5px;
line-height: 1.55;
color: var(--d-g700);
}
.dr-context-i {
display: flex;
gap: 9px;
fa-icon {
color: var(--d-blue);
margin-top: 2px;
}
}
.dr-context-r {
margin-top: 3px;
color: var(--d-g500);
}
.dr-context-t {
white-space: nowrap;
font-size: 11.5px;
color: var(--d-g500);
}
.dr-context-c {
font-weight: 700;
color: var(--d-g900);
}
/* ── Buttons ────────────────────────────────────────────────────────────── */
.dr-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 13px;
border: 1px solid var(--d-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 12px;
font-weight: 600;
color: var(--d-g700);
cursor: pointer;
&:hover:not(:disabled) {
border-color: var(--d-blue);
color: var(--d-blue);
}
&:disabled {
opacity: 0.55;
cursor: not-allowed;
}
&--slim {
padding: 6px 11px;
font-size: 11.5px;
}
&--primary {
background: var(--d-blue);
border-color: var(--d-blue);
color: #fff;
&:hover:not(:disabled) {
background: #1e40af;
color: #fff;
}
}
}
.dr-mini {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 9px;
border: 1px solid var(--d-g300);
border-radius: 5px;
background: #fff;
font: inherit;
font-size: 10.5px;
font-weight: 600;
color: var(--d-g700);
cursor: pointer;
&:hover {
border-color: var(--d-blue);
color: var(--d-blue);
}
}
/* ── Table ──────────────────────────────────────────────────────────────── */
.dr-tablewrap {
overflow-x: auto;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
}
.dr-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
th {
text-align: left;
padding: 9px 10px;
background: var(--d-g100);
border-bottom: 1px solid var(--d-g200);
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.4px;
color: var(--d-g500);
white-space: nowrap;
&.num {
text-align: right;
}
}
td {
padding: 9px 10px;
border-bottom: 1px solid var(--d-g100);
vertical-align: top;
}
tbody tr:hover {
background: var(--d-g50);
}
}
/* A dormant account inside a year of the DEAF mark is the one to act on now. */
.dr-row--warn {
background: var(--d-amber-l);
&:hover {
background: #fde68a;
}
}
.dr-sub {
font-size: 10.5px;
color: var(--d-g500);
margin-top: 2px;
&--red {
color: var(--d-amber);
font-weight: 700;
}
}
.dr-prod {
max-width: 220px;
font-size: 11.5px;
}
.dr-next {
font-size: 11px;
font-weight: 600;
color: var(--d-g700);
}
.dr-ref {
max-width: 200px;
}
.dr-badge {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
&--info {
background: var(--d-blue-l);
color: var(--d-blue);
}
&--warn {
background: var(--d-amber-l);
color: var(--d-amber);
}
&--critical {
background: var(--d-crit-l);
color: var(--d-crit);
}
}
.dr-rem {
display: inline-block;
padding: 2px 9px;
border-radius: 999px;
font-size: 10.5px;
font-weight: 700;
&--ok {
background: var(--d-green-l);
color: var(--d-green);
}
/* Money out of the customer's account but not yet with RBI — never shown as settled. */
&--pending {
background: var(--d-red-l);
color: var(--d-red);
}
}
.dr-empty {
padding: 34px;
text-align: center;
font-size: 12.5px;
color: var(--d-g500);
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
}
.dr-foot {
display: flex;
gap: 10px;
padding: 11px 14px;
margin-top: 12px;
background: #fff;
border: 1px solid var(--d-g200);
border-radius: 7px;
font-size: 11.5px;
line-height: 1.6;
color: var(--d-g500);
fa-icon {
color: var(--d-g500);
margin-top: 2px;
}
}
/* ── Selection & remittance bar ─────────────────────────────────────────── */
.dr-bar {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.dr-spacer {
flex: 1;
}
.dr-selinfo {
font-size: 11.5px;
font-weight: 700;
color: var(--d-g700);
}
.dr-tick {
width: 34px;
text-align: center;
input {
cursor: pointer;
}
/* An already-remitted row cannot be picked again; the cursor should say so. */
input:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}
.dr-row--sel {
background: var(--d-blue-l);
}
.dr-act {
white-space: nowrap;
.dr-mini + .dr-mini {
margin-left: 4px;
}
}
/* ── Modal ──────────────────────────────────────────────────────────────── */
.dr-modal-bg {
position: fixed;
inset: 0;
background: rgb(15 23 42 / 45%);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
}
.dr-modal {
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
background: #fff;
border-radius: 9px;
box-shadow: 0 18px 50px rgb(15 23 42 / 26%);
&--sm {
max-width: 450px;
}
}
.dr-modal-h {
display: flex;
align-items: center;
justify-content: space-between;
padding: 13px 17px;
border-bottom: 1px solid var(--d-g200);
h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
color: var(--d-navy);
}
}
.dr-x {
border: 0;
background: transparent;
font-size: 22px;
line-height: 1;
color: var(--d-g500);
cursor: pointer;
}
.dr-modal-b {
padding: 15px 17px;
}
.dr-modal-f {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 12px 17px;
border-top: 1px solid var(--d-g200);
}
.dr-modal-sum {
display: flex;
gap: 26px;
padding-bottom: 12px;
margin-bottom: 12px;
border-bottom: 1px solid var(--d-g100);
}
.dr-modal-n {
display: block;
font-size: 20px;
font-weight: 700;
line-height: 1.1;
}
.dr-modal-l {
font-size: 10.5px;
color: var(--d-g500);
}
.dr-modal-who {
display: flex;
align-items: center;
gap: 9px;
flex-wrap: wrap;
padding-bottom: 11px;
margin-bottom: 11px;
border-bottom: 1px solid var(--d-g100);
font-size: 12.5px;
}
.dr-modal-p {
margin: 12px 0 5px;
font-size: 11.5px;
font-weight: 700;
color: var(--d-g700);
}
.dr-modal-ul {
margin: 0;
padding-left: 18px;
font-size: 11.5px;
line-height: 1.75;
color: var(--d-g700);
}
/* Marking an account exempt is a decision on record, not a convenience. */
.dr-modal-warn {
margin: 12px 0 0;
padding: 8px 11px;
background: var(--d-amber-l);
border-radius: 5px;
font-size: 11px;
line-height: 1.55;
color: var(--d-amber);
}
.dr-lbl {
display: block;
margin: 12px 0 5px;
font-size: 11px;
font-weight: 700;
color: var(--d-g700);
}
.dr-req {
color: var(--d-red);
}
.dr-inp--full {
width: 100%;
}
.dr-ta {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--d-g300);
border-radius: 5px;
font: inherit;
font-size: 12px;
resize: vertical;
}
.dr-note-box {
display: flex;
gap: 8px;
padding: 9px 11px;
background: var(--d-blue-l);
border-radius: 5px;
font-size: 11px;
line-height: 1.55;
color: var(--d-g700);
fa-icon {
color: var(--d-blue);
margin-top: 1px;
}
}
.dr-err {
margin-top: 10px;
padding: 7px 10px;
background: var(--d-red-l);
border-radius: 5px;
font-size: 11.5px;
color: var(--d-red);
}
.dr-toast {
position: fixed;
bottom: 22px;
left: 50%;
transform: translateX(-50%);
padding: 10px 20px;
background: var(--d-g900);
color: #fff;
border-radius: 6px;
font-size: 12.5px;
box-shadow: 0 8px 22px rgb(15 23 42 / 30%);
z-index: 1100;
}
/* Where the escheated money currently stands, read at a glance above the table. */
.dr-money {
display: inline-flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
margin-left: 14px;
}
.dr-money-i {
font-size: 11px;
font-weight: 700;
color: var(--d-g500);
&--bad {
color: var(--d-red);
}
&--ok {
color: var(--d-green);
}
}
/* A balance the customer has taken back is no longer with the fund. */
.dr-claimed {
margin-top: 3px;
font-size: 10px;
font-weight: 700;
color: var(--d-blue);
}
@@ -0,0 +1,585 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router } from '@angular/router';
/** Custom Modules */
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
/** Custom Services */
import { ClientsService } from '../clients/clients.service';
/** Shared report helpers */
import { exportWorkbook } from '../cashier/cashier-export';
/** Report data + computation */
import {
DORMANCY_POLICY,
DORMANCY_STAGES,
DormancyRow,
DormancyStage,
DormancySummary,
daysBetween,
dormancySummary,
nextStage,
sortByIdle,
stageFromSubStatus
} from './dormancy-report.compute';
/**
* The three reports on accounts that have gone quiet.
*
* They are one screen rather than three because they are one progression: an account moves from
* inactive to dormant to escheat as the same silence lengthens, and an officer reading one usually
* wants to see what is coming behind it. The filters at the top therefore apply to all three, and
* the tabs only choose which stage is on display.
*
* The escheat tab carries the figure that matters most — money taken out of customers' accounts
* that has not yet reached RBI's DEAF. Nothing in this system performs that remittance, so without
* a report on it the gap is invisible.
*/
@Component({
selector: 'mifosx-dormancy-report',
templateUrl: './dormancy-report.component.html',
styleUrls: ['./dormancy-report.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule,
FaIconComponent
]
})
export class DormancyReportComponent implements OnInit {
private router = inject(Router);
private clientsService = inject(ClientsService);
loading = false;
errorMessage = '';
rows: DormancyRow[] = [];
today = new Date().toISOString().slice(0, 10);
/** Which of the three reports is on display. */
tab: DormancyStage = 'DORMANT';
/** Filters, shared by all three tabs. */
branchCode = '';
search = '';
minBalance: number | null = null;
pendingRemittanceOnly = false;
/**
* Branch options come from the rows themselves rather than a fixed list: the server returns the
* office name it holds, and a dropdown built from anywhere else would offer branches that match
* nothing and omit ones that do.
*/
get branches(): string[] {
return [...new Set(this.rows.map((r) => r.branchCode).filter((b) => !!b))].sort();
}
readonly policy = DORMANCY_POLICY;
readonly stageMeta = DORMANCY_STAGES;
readonly tabs: DormancyStage[] = [
'INACTIVE',
'DORMANT',
'ESCHEAT'
];
ngOnInit(): void {
this.load();
}
/**
* Loads the accounts the dormancy job has marked.
*
* The stage is taken from the sub-status the job wrote rather than recomputed from the idle
* period. The sub-status is what actually restricts the account, so a report that worked it out
* independently could disagree with the account's real state and send officers after the wrong
* ones.
*/
load(): void {
this.loading = true;
this.errorMessage = '';
this.clientsService.getDormantAccounts().subscribe({
next: (accounts: any[]) => {
this.rows = sortByIdle(
(accounts || []).map((a) => this.mapAccount(a)).filter((r): r is DormancyRow => r !== null)
);
this.loading = false;
},
error: (err: any) => {
this.rows = [];
this.loading = false;
this.errorMessage =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not load the dormancy reports.';
}
});
}
// ── Recording a DEAF remittance ────────────────────────────────────────────
/** Accounts ticked on the escheat tab, by account id. */
selected = new Set<number>();
remitOpen = false;
remitDate = new Date().toISOString().slice(0, 10);
remitReference = '';
remitRemarks = '';
remitSaving = false;
remitError = '';
toggleSelect(row: DormancyRow, event: Event): void {
event.stopPropagation();
if (this.selected.has(row.accountId)) {
this.selected.delete(row.accountId);
} else {
this.selected.add(row.accountId);
}
}
/** Ticks every unremitted row on display, or clears them if all are already ticked. */
toggleSelectAll(): void {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
if (selectable.every((r) => this.selected.has(r.accountId))) {
selectable.forEach((r) => this.selected.delete(r.accountId));
} else {
selectable.forEach((r) => this.selected.add(r.accountId));
}
}
/**
* Drops the whole selection.
*
* A method rather than `selected.clear()` in the template — Angular's expression syntax does not
* allow calling built-in methods on a Set.
*/
clearSelection(): void {
this.selected.clear();
}
get allSelected(): boolean {
const selectable = this.visibleRows.filter((r) => r.remittance !== 'REMITTED');
return selectable.length > 0 && selectable.every((r) => this.selected.has(r.accountId));
}
get selectedTotal(): number {
return this.rows.filter((r) => this.selected.has(r.accountId)).reduce((s, r) => s + r.balance, 0);
}
openRemit(): void {
this.remitDate = new Date().toISOString().slice(0, 10);
this.remitReference = '';
this.remitRemarks = '';
this.remitError = '';
this.remitOpen = true;
}
/**
* Writes the remittance against every ticked account.
*
* The server skips anything not escheated or already recorded, so the result is reported back as
* counts rather than assumed — a batch that silently recorded fewer rows than were ticked would
* leave the officer believing money was accounted for when it was not.
*/
saveRemittance(): void {
if (this.selected.size === 0) {
this.remitError = 'Select at least one account.';
return;
}
if (!this.remitDate) {
this.remitError = 'Enter the date the money was sent to RBI.';
return;
}
this.remitSaving = true;
this.remitError = '';
this.clientsService
.recordDeafRemittance({
savingsAccountIds: [...this.selected],
remittedOn: this.remitDate,
deafReference: this.remitReference.trim() || undefined,
remarks: this.remitRemarks.trim() || undefined
})
.subscribe({
next: (res: any) => {
this.remitSaving = false;
this.remitOpen = false;
const recorded = res?.changes?.recorded ?? res?.recorded ?? this.selected.size;
const skipped = res?.changes?.skipped ?? res?.skipped ?? 0;
this.selected.clear();
this.toast(
skipped > 0
? `${recorded} remittance(s) recorded, ${skipped} skipped — already recorded or not escheated.`
: `${recorded} remittance(s) recorded.`
);
this.load();
},
error: (err: any) => {
this.remitSaving = false;
this.remitError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the remittance.';
}
});
}
// ── Customer claiming a remitted balance back ──────────────────────────────
refundOpen = false;
refundRow: DormancyRow | null = null;
refundDate = new Date().toISOString().slice(0, 10);
refundSaving = false;
refundError = '';
/**
* Opens the claim dialog for a balance already sent to the fund.
*
* A customer can claim an unclaimed deposit at any time, however long it has been with DEAF. The
* bank pays them and recovers the amount from RBI, so recording it here keeps the remitted total
* honest rather than leaving money counted as still sitting in the fund.
*/
openRefund(row: DormancyRow, event: Event): void {
event.stopPropagation();
this.refundRow = row;
this.refundDate = new Date().toISOString().slice(0, 10);
this.refundError = '';
this.refundOpen = true;
}
saveRefund(): void {
if (!this.refundRow) {
return;
}
if (!this.refundDate) {
this.refundError = 'Enter the date the customer was paid.';
return;
}
const row = this.refundRow;
this.refundSaving = true;
this.refundError = '';
this.clientsService.refundDeafRemittance(row.accountId, this.refundDate).subscribe({
next: () => {
this.refundSaving = false;
this.refundOpen = false;
this.toast(`Claim recorded for ${row.clientName}.`);
this.load();
},
error: (err: any) => {
this.refundSaving = false;
this.refundError =
err?.error?.defaultUserMessage || err?.error?.developerMessage || 'Could not record the claim.';
}
});
}
toastMsg = '';
toastVisible = false;
private toastTimer: any;
toast(msg: string): void {
this.toastMsg = msg;
this.toastVisible = true;
clearTimeout(this.toastTimer);
this.toastTimer = setTimeout(() => (this.toastVisible = false), 4000);
}
/** Maps one DormantAccountData row onto what the three reports render. */
private mapAccount(a: any): DormancyRow | null {
const stage = stageFromSubStatus(Number(a?.subStatus));
if (!stage) {
// Only the three dormancy states belong here; anything else is not on this ladder.
return null;
}
const lastTxn = (a?.lastCustomerTransactionDate || '').toString().slice(0, 10);
const next = lastTxn ? nextStage(lastTxn, stage) : { date: '', label: '' };
return {
accountId: a?.accountId,
accountNo: a?.accountNo || '—',
clientId: a?.clientId,
clientName: a?.clientName || '—',
branchCode: a?.officeName || '',
productName: a?.productName || '—',
balance: Number(a?.balance ?? 0),
lastCustomerTxnDate: lastTxn,
daysIdle: Number(a?.daysIdle ?? 0),
stage,
subStatus: Number(a?.subStatus ?? 0),
nextStageDate: next.date,
nextStageLabel: next.label,
// An escheated account counts as remitted only when a remittance has actually been recorded
// against it. Absence of that row is the whole point of the report: the money has left the
// customer's account but is not yet accounted for as having reached RBI.
remittance: stage === 'ESCHEAT' ? (a?.deafRemittedOn ? 'REMITTED' : 'PENDING') : undefined,
escheatedOn: (a?.closedOnDate || '').toString().slice(0, 10) || undefined,
remittedOn: (a?.deafRemittedOn || '').toString().slice(0, 10) || undefined,
deafReference: a?.deafReference || undefined,
refundedOn: (a?.deafRefundedOn || '').toString().slice(0, 10) || undefined,
mobile: a?.mobileNo || ''
};
}
// ── Filtering ──────────────────────────────────────────────────────────────
/** Rows on the tab in view, after the shared filters. */
get visibleRows(): DormancyRow[] {
return this.rowsForStage(this.tab);
}
private rowsForStage(stage: DormancyStage): DormancyRow[] {
const needle = this.search.trim().toLowerCase();
return this.rows.filter((r) => {
if (r.stage !== stage) {
return false;
}
if (this.branchCode && r.branchCode !== this.branchCode) {
return false;
}
if (this.minBalance != null && r.balance < this.minBalance) {
return false;
}
// Only meaningful on the escheat tab, where a row can be awaiting remittance.
if (this.pendingRemittanceOnly && r.remittance !== 'PENDING') {
return false;
}
if (needle) {
const hay = `${r.clientName} ${r.clientId} ${r.accountNo}`.toLowerCase();
if (!hay.includes(needle)) {
return false;
}
}
return true;
});
}
/** Tab counts respect the filters, so the badge matches what the tab will show. */
countFor(stage: DormancyStage): number {
return this.rowsForStage(stage).length;
}
get summary(): DormancySummary {
return dormancySummary(this.rows, this.today);
}
get visibleBalance(): number {
return this.visibleRows.reduce((s, r) => s + r.balance, 0);
}
resetFilters(): void {
this.branchCode = '';
this.search = '';
this.minBalance = null;
this.pendingRemittanceOnly = false;
}
showTab(stage: DormancyStage): void {
this.tab = stage;
// The remittance filter only exists on the escheat tab; carrying it elsewhere would silently
// empty a tab that has no remittance state at all.
if (stage !== 'ESCHEAT') {
this.pendingRemittanceOnly = false;
}
}
// ── Presentation ───────────────────────────────────────────────────────────
/** The server already returns the office by name, so there is nothing to look up. */
branchName(office: string): string {
return office || '—';
}
toneClass(stage: DormancyStage): string {
return `dr-badge dr-badge--${DORMANCY_STAGES[stage].tone}`;
}
/** "3 years 2 months" reads better than a four-digit day count. */
idleText(days: number): string {
const years = Math.floor(days / 365);
const months = Math.floor((days % 365) / 30);
if (years === 0) {
return `${months} month${months === 1 ? '' : 's'}`;
}
if (months === 0) {
return `${years} year${years === 1 ? '' : 's'}`;
}
return `${years}y ${months}m`;
}
/** Days until the next mark, for the accounts approaching one. */
daysToNext(row: DormancyRow): number | null {
return row.nextStageDate ? daysBetween(this.today, row.nextStageDate) : null;
}
/** Flags a dormant account within a year of the ten-year mark. */
isNearingEscheat(row: DormancyRow): boolean {
const days = this.daysToNext(row);
return row.stage === 'DORMANT' && days !== null && days <= 365;
}
remittanceClass(state?: string): string {
return state === 'REMITTED' ? 'dr-rem dr-rem--ok' : 'dr-rem dr-rem--pending';
}
openClient(row: DormancyRow, event: Event): void {
event.stopPropagation();
this.router.navigate([
'/clients',
row.clientId,
'general'
]);
}
// ── Export ─────────────────────────────────────────────────────────────────
/**
* Exports all three reports, not just the tab in view.
*
* They are read together — an auditor asking about escheat invariably asks what is behind it —
* and a workbook holding one of the three would only have to be produced three times.
*/
exportExcel(): void {
const stamp = `As on ${this.today}${this.branchCode ? ` · ${this.branchName(this.branchCode)}` : ' · All branches'}`;
exportWorkbook(
[
{
sheetName: 'Inactive',
title: `Inactive Accounts — idle ${this.policy.inactiveDays}+ days`,
subtitle: stamp,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Product',
'Balance',
'Last Customer Txn',
'Idle',
'Becomes Dormant On'
],
widths: [
18,
28,
12,
22,
30,
14,
18,
12,
20
],
rows: this.rowsForStage('INACTIVE').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.productName,
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.nextStageDate
])
},
{
sheetName: 'Dormant',
title: `Dormant / Inoperative Accounts — idle ${this.policy.dormantDays}+ days`,
subtitle: stamp,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Product',
'Balance',
'Last Customer Txn',
'Idle',
'Due to DEAF On',
'Nearing DEAF'
],
widths: [
18,
28,
12,
22,
30,
14,
18,
12,
18,
14
],
rows: this.rowsForStage('DORMANT').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.productName,
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.nextStageDate,
this.isNearingEscheat(r) ? 'Within 1 year' : ''
])
},
{
sheetName: 'Escheat & DEAF',
title: `Escheated Accounts — idle ${this.policy.escheatDays}+ days`,
subtitle: `${stamp} · Not yet remitted to RBI: ₹${this.summary.pendingRemittance.toLocaleString('en-IN')}`,
headers: [
'Account No',
'Customer',
'Customer ID',
'Branch',
'Balance Escheated',
'Last Customer Txn',
'Idle',
'Escheated On',
'Remittance to RBI',
'Remitted On',
'DEAF Reference',
'Claimed Back On'
],
widths: [
18,
28,
12,
22,
18,
18,
12,
14,
16,
14,
24,
16
],
rows: this.rowsForStage('ESCHEAT').map((r) => [
r.accountNo,
r.clientName,
String(r.clientId),
this.branchName(r.branchCode),
r.balance,
r.lastCustomerTxnDate,
this.idleText(r.daysIdle),
r.escheatedOn || '',
r.remittance === 'REMITTED' ? 'Remitted' : 'Not remitted',
r.remittedOn || '',
r.deafReference || '',
r.refundedOn || ''
])
}
],
`dormancy-reports-${this.today}`
);
}
}
@@ -0,0 +1,218 @@
/**
* 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/.
*/
/**
* Accounts that have gone quiet, and what has happened to them.
*
* RBI counts the silence from the last *customer-induced* transaction — a deposit or a withdrawal
* the customer made. Interest the bank posts and charges it recovers are the bank operating the
* account, not the customer using it, so they do not reset the clock. An account crosses three
* marks: inactive at a year, inoperative at two, and due to the Depositor Education and Awareness
* Fund at ten.
*
* The escheat stage is the one that needs watching. Sending the balance to DEAF is a transfer to
* RBI that happens outside this system, so an account can sit closed here with its money not yet
* remitted. That gap is what the third report exists to show.
*/
/** The three marks an idle account passes. */
export type DormancyStage = 'INACTIVE' | 'DORMANT' | 'ESCHEAT';
/** Sub-status codes as stored on the savings account. */
export const SUB_STATUS = {
NONE: 0,
INACTIVE: 100,
DORMANT: 200,
ESCHEAT: 300
};
/** RBI periods, in days from the last customer transaction. */
export const DORMANCY_POLICY = {
inactiveDays: 365,
dormantDays: 730,
escheatDays: 3650
};
export interface DormancyStageMeta {
stage: DormancyStage;
label: string;
tone: 'info' | 'warn' | 'critical';
afterDays: number;
/** What the customer can still do. */
effect: string;
/** How the account comes back, in the officer's words. */
revival: string;
}
export const DORMANCY_STAGES: Record<DormancyStage, DormancyStageMeta> = {
INACTIVE: {
stage: 'INACTIVE',
label: 'Inactive',
tone: 'info',
afterDays: DORMANCY_POLICY.inactiveDays,
effect: 'Nothing is restricted. The account is flagged internally so the branch can reach out.',
revival: 'Any customer deposit or withdrawal clears the flag at once.'
},
DORMANT: {
stage: 'DORMANT',
label: 'Dormant / Inoperative',
tone: 'warn',
afterDays: DORMANCY_POLICY.dormantDays,
effect: 'Debits are stopped. Credits still land, so pension and benefit transfers are unaffected.',
revival: 'The customer completes KYC at the branch and makes one transaction — no reactivation charge.'
},
ESCHEAT: {
stage: 'ESCHEAT',
label: 'Escheat — due to DEAF',
tone: 'critical',
afterDays: DORMANCY_POLICY.escheatDays,
effect: 'The account is closed and the balance is taken out, to be remitted to RBI’s DEAF.',
revival: 'The customer can still claim the money from the bank, which then recovers it from DEAF.'
}
};
/** Whether the balance taken out at escheat has actually reached RBI. */
export type DeafRemittanceState = 'PENDING' | 'REMITTED';
export interface DormancyRow {
accountId: number;
accountNo: string;
clientId: number;
clientName: string;
branchCode: string;
productName: string;
balance: number;
/** Last transaction the customer themselves made. */
lastCustomerTxnDate: string;
daysIdle: number;
stage: DormancyStage;
/** Sub-status currently on the account. */
subStatus: number;
/** The day the next mark is crossed; blank once escheated. */
nextStageDate: string;
nextStageLabel: string;
/** Escheat only — whether the money has gone to RBI. */
remittance?: DeafRemittanceState;
/** Escheat only — the day the balance was taken out of the account. */
escheatedOn?: string;
/** Escheat only — the day it was remitted, once it has been. */
remittedOn?: string;
/** Escheat only — RBI's reference for the remittance. */
deafReference?: string;
/** Escheat only — set if the customer claimed the balance back after it went to the fund. */
refundedOn?: string;
mobile: string;
}
function addDays(date: string, days: number): string {
const d = new Date(`${date}T00:00:00Z`);
return new Date(d.getTime() + days * 86400000).toISOString().slice(0, 10);
}
export 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);
}
/**
* The stage an account is at, from the sub-status the dormancy job wrote on it.
*
* Preferred over deriving it from the idle period, because the sub-status is what actually
* restricts the account. Anything outside the three dormancy codes — a manual block, an active
* account — is not on this ladder and returns null.
*/
export function stageFromSubStatus(subStatus: number): DormancyStage | null {
if (subStatus === SUB_STATUS.INACTIVE) {
return 'INACTIVE';
}
if (subStatus === SUB_STATUS.DORMANT) {
return 'DORMANT';
}
if (subStatus === SUB_STATUS.ESCHEAT) {
return 'ESCHEAT';
}
return null;
}
/** Which mark an account has passed, from how long it has been idle. */
export function dormancyStage(daysIdle: number): DormancyStage | null {
if (daysIdle >= DORMANCY_POLICY.escheatDays) {
return 'ESCHEAT';
}
if (daysIdle >= DORMANCY_POLICY.dormantDays) {
return 'DORMANT';
}
if (daysIdle >= DORMANCY_POLICY.inactiveDays) {
return 'INACTIVE';
}
return null;
}
/** The next mark and the day it falls due. */
export function nextStage(lastTxnDate: string, stage: DormancyStage): { date: string; label: string } {
if (stage === 'INACTIVE') {
return { date: addDays(lastTxnDate, DORMANCY_POLICY.dormantDays), label: 'Becomes dormant' };
}
if (stage === 'DORMANT') {
return { date: addDays(lastTxnDate, DORMANCY_POLICY.escheatDays), label: 'Balance due to DEAF' };
}
// Escheat is the last mark — nothing further happens to the account on its own.
return { date: '', label: 'Awaiting DEAF remittance' };
}
export interface DormancySummary {
inactive: number;
dormant: number;
escheat: number;
inactiveBalance: number;
dormantBalance: number;
escheatBalance: number;
/** Escheated money not yet sent to RBI — the figure that must not be left to drift. */
pendingRemittance: number;
pendingRemittanceCount: number;
/** Balances that reached the fund and are still with it. */
remittedBalance: number;
/** Balances since claimed back by the customer and recovered from the fund. */
claimedBackBalance: number;
claimedBackCount: number;
/** Dormant accounts approaching the ten-year mark within a year. */
nearingEscheat: number;
}
export function dormancySummary(rows: DormancyRow[], today: string): DormancySummary {
const inactive = rows.filter((r) => r.stage === 'INACTIVE');
const dormant = rows.filter((r) => r.stage === 'DORMANT');
const escheat = rows.filter((r) => r.stage === 'ESCHEAT');
const pending = escheat.filter((r) => r.remittance === 'PENDING');
const claimedBack = escheat.filter((r) => r.refundedOn);
// A balance the customer has since claimed back is no longer with the fund, so it is counted
// separately rather than left in the remitted total.
const stillWithFund = escheat.filter((r) => r.remittance === 'REMITTED' && !r.refundedOn);
const sum = (list: DormancyRow[]) => list.reduce((s, r) => s + r.balance, 0);
return {
inactive: inactive.length,
dormant: dormant.length,
escheat: escheat.length,
inactiveBalance: sum(inactive),
dormantBalance: sum(dormant),
escheatBalance: sum(escheat),
pendingRemittance: sum(pending),
pendingRemittanceCount: pending.length,
remittedBalance: sum(stillWithFund),
claimedBackBalance: sum(claimedBack),
claimedBackCount: claimedBack.length,
nearingEscheat: dormant.filter((r) => r.nextStageDate && daysBetween(today, r.nextStageDate) <= 365).length
};
}
/** Idle longest first — the accounts closest to the next mark need attention first. */
export function sortByIdle(rows: DormancyRow[]): DormancyRow[] {
return [...rows].sort((a, b) => b.daysIdle - a.daysIdle || b.balance - a.balance);
}