KYC management: stage-wise notices, pagination, RD base chart

This commit is contained in:
2026-10-20 17:23:42 +05:30
parent 3e5cc26e49
commit 357f666421
108 changed files with 15001 additions and 2721 deletions
@@ -0,0 +1,852 @@
<!--
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="bkd">
<div class="bkd-head">
<div class="bkd-head-left">
<div class="bkd-head-ic"><fa-icon icon="balance-scale"></fa-icon></div>
<div>
<h2 class="bkd-title">ALM Time-Bucket Breakdown</h2>
<p class="bkd-sub">Day-wise breakdown of FD / RD / Loan from the as-on date</p>
</div>
</div>
<div class="bkd-head-actions">
@if (view === 'config') {
<button type="button" class="bkd-btn" [class.bkd-btn-on]="editMode" (click)="toggleEdit()">
{{ editMode ? 'Done editing lines' : 'Edit lines' }}
</button>
<button type="button" class="bkd-btn" (click)="reset()">Reset to RBI defaults</button>
}
<button class="bkd-close" (click)="close()" matTooltip="Close" aria-label="Close">&#10005;</button>
</div>
</div>
<div class="bkd-tabs" role="tablist">
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'days'" (click)="view = 'days'">
Day-wise breakdown
</button>
<button type="button" role="tab" class="bkd-tab" [class.on]="view === 'config'" (click)="view = 'config'">
Configuration table
</button>
</div>
<div class="bkd-body">
@if (view === 'config') {
<p class="bkd-note">
<fa-icon icon="info-circle"></fa-icon>&nbsp; Each line shows which bucket its money goes to. You can change the
<strong>%</strong> and the rule. Changes are saved in this browser straight away. Hover over a line name to see
why it is slotted that way.
</p>
@if (problemCount > 0) {
<p class="bkd-warn bkd-banner">
{{ problemCount }} line(s) have a % total that is not 100 or is not filled in yet. See the red or yellow
totals below.
</p>
}
}
@if (view === 'config') {
<!-- The date the figures are as on, and how they are read -->
<div class="bkd-toolbar">
<label>
<span>As-on date</span>
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="loadBreakdown()" aria-label="As-on date" />
</label>
<label>
<span>Amount unit</span>
<select [(ngModel)]="unit" aria-label="Unit">
@for (u of units; track u.value) {
<option [value]="u.value">{{ u.label }}</option>
}
</select>
</label>
<div class="bkd-toolbar-state">
@if (loading) {
<span>Loading data from accounts…</span>
} @else if (loadError) {
<span class="bkd-warn">{{ loadError }}</span>
} @else if (breakdown) {
<span class="bkd-ok"> FD, RD and Loan data as on {{ breakdown.asOnDate }}. </span>
}
</div>
</div>
}
@if (view === 'days') {
<section class="bkd-days-view">
<h4 class="bkd-h4 bkd-h4-first">1. Select</h4>
<div class="bkd-modes" role="tablist">
<button type="button" class="bkd-mode" [class.on]="selectBy === 'types'" (click)="setSelectBy('types')">
By account type
</button>
<button type="button" class="bkd-mode" [class.on]="selectBy === 'accounts'" (click)="setSelectBy('accounts')">
Individual accounts
</button>
</div>
@if (selectBy === 'types') {
<p class="bkd-days-intro">Tick one or more. You can pick from both sides together.</p>
@for (grp of pickGroups; track grp.side) {
<div class="bkd-pick-group">
<div
class="bkd-pick-title"
[class.bkd-pick-liab]="grp.side === 'liability'"
[class.bkd-pick-asset]="grp.side === 'asset'"
>
{{ grp.title }}
</div>
<div class="bkd-pick-list">
@for (t of grp.items; track t.value) {
<button
type="button"
class="bkd-pick-opt"
[class.on]="isTypePicked(t.value)"
role="checkbox"
[attr.aria-checked]="isTypePicked(t.value)"
(click)="toggleType(t.value)"
>
<span class="bkd-pick-box">{{ isTypePicked(t.value) ? '✓' : '' }}</span>
{{ t.label }}
</button>
}
</div>
</div>
}
<div class="bkd-pick-actions">
<button type="button" class="bkd-link" (click)="selectAllTypes()">Select all</button>
<button type="button" class="bkd-link" (click)="clearTypes()">Clear</button>
</div>
} @else {
<div class="bkd-search">
<input
type="text"
[(ngModel)]="searchText"
(ngModelChange)="onSearchInput()"
placeholder="Search by account number or customer name"
aria-label="Search accounts"
/>
<select [(ngModel)]="searchKind" (ngModelChange)="onSearchInput()" aria-label="Kind of account">
<option value="">All accounts</option>
<option value="FD">FD</option>
<option value="RD">RD</option>
<option value="LOAN">Loan</option>
</select>
</div>
@if (searching) {
<p class="bkd-days-note">Searching…</p>
} @else if (searchError) {
<p class="bkd-warn">{{ searchError }}</p>
} @else if (searchText.trim().length < 2) {
<p class="bkd-days-note">Type at least 2 characters of an account number or a customer name.</p>
} @else if (!searchResults.length) {
<p class="bkd-days-note">No accounts found.</p>
}
@if (searchResults.length) {
<div class="bkd-hits">
@for (hit of searchResults; track hit.kind + '-' + hit.id) {
<button type="button" class="bkd-hit" [class.picked]="isPicked(hit)" (click)="pick(hit)">
<span class="bkd-hit-kind">{{ hit.kind }}</span>
<span class="bkd-hit-main">
<strong>{{ hit.accountNo }}</strong> &middot; {{ hit.customer }}
<small
>{{ hit.product }}
@if (hit.date) {
&middot; matures {{ hit.date | date: 'dd-MMM-yyyy' }}
}
</small>
</span>
<span class="bkd-hit-amt">{{ amountText(hit.amount) }}</span>
<span class="bkd-hit-state">{{ isPicked(hit) ? 'Selected' : 'Select' }}</span>
</button>
}
</div>
}
@if (picked.length) {
<div class="bkd-picked">
<strong>Selected ({{ picked.length }})</strong>
@for (p of picked; track p.kind + '-' + p.id) {
<span class="bkd-pill-sel">
{{ p.kind }} {{ p.accountNo }}
<button type="button" (click)="unpick(p)" aria-label="Remove">&#10005;</button>
</span>
}
<button type="button" class="bkd-link" (click)="clearPicked()">Clear all</button>
</div>
}
}
<h4 class="bkd-h4">2. Date and unit</h4>
<div class="bkd-submit-row">
<label>
<span>As-on date</span>
<input type="date" [(ngModel)]="asOnIso" (ngModelChange)="onAsOnChange()" aria-label="As-on date" />
</label>
<label>
<span>Amount unit</span>
<select [(ngModel)]="unit" aria-label="Amount unit">
@for (u of units; track u.value) {
<option [value]="u.value">{{ u.label }}</option>
}
</select>
</label>
<button type="button" class="bkd-btn bkd-btn-on bkd-submit" [disabled]="!canSubmit" (click)="submitReport()">
Submit
</button>
</div>
@if (reportShown && selectBy === 'types') {
<section class="bkd-report-out">
<h4 class="bkd-h4 bkd-report-title">
{{ typeColumns.length }} account {{ typeColumns.length === 1 ? 'type' : 'types' }}
<small>as on {{ asOnIso }} &middot; Amounts in {{ unitWord }}</small>
</h4>
@if (loading) {
<p class="bkd-days-note">Loading data from accounts…</p>
}
@if (loadError) {
<p class="bkd-warn">{{ loadError }}</p>
}
@if (breakdown) {
@for (c of typeColumns; track c.value) {
@if (c.notice) {
<p class="bkd-warn bkd-banner">{{ c.label }}: {{ c.notice }}</p>
}
}
<div class="bkd-acc-wrap bkd-matrix-wrap">
<table class="bkd-tbl bkd-matrix">
<thead>
<tr>
<th>Bucket</th>
<th>Days</th>
<th>Dates</th>
@for (c of typeColumns; track c.value) {
<th
class="bkd-num"
[class.bkd-pick-liab]="c.side === 'liability'"
[class.bkd-pick-asset]="c.side === 'asset'"
>
{{ c.label }}
</th>
}
@if (typeHasLiability && typeHasAsset) {
<th class="bkd-num bkd-pick-liab">Liabilities total</th>
<th class="bkd-num bkd-pick-asset">Assets total</th>
} @else if (typeColumns.length > 1) {
<th class="bkd-num">Total</th>
}
</tr>
</thead>
<tbody>
@for (r of typeMatrix; track r.label) {
<tr [class.bkd-dim]="!r.liab && !r.asset">
<td class="bkd-b-name">{{ r.label }}</td>
<td>{{ r.days }}</td>
<td>{{ r.dates }}</td>
@for (v of r.cells; track $index) {
<td class="bkd-num">{{ amountText(v) }}</td>
}
@if (typeHasLiability && typeHasAsset) {
<td class="bkd-num">{{ amountText(r.liab) }}</td>
<td class="bkd-num">{{ amountText(r.asset) }}</td>
} @else if (typeColumns.length > 1) {
<td class="bkd-num">{{ amountText(r.liab + r.asset) }}</td>
}
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
@for (c of typeColumns; track c.value) {
<td class="bkd-num">{{ amountText(c.total) }}</td>
}
@if (typeHasLiability && typeHasAsset) {
<td class="bkd-num">{{ amountText(liabilityGrandTotal) }}</td>
<td class="bkd-num">{{ amountText(assetGrandTotal) }}</td>
} @else if (typeColumns.length > 1) {
<td class="bkd-num">{{ amountText(liabilityGrandTotal + assetGrandTotal) }}</td>
}
</tr>
</tfoot>
</table>
</div>
<p class="bkd-days-note">
Deposits and term loans are slotted by their own dates: a deposit once on its maturity date, a loan once
for each unpaid instalment on its due date. Anything already due goes in the first bucket (a loan
instalment follows the Overdue rule). CC / OD and NPA loans are divided by the bucket-wise % set for
them in the Configuration table. Deposit amounts are balances, loan amounts are principal; interest is a
separate line of the statement.
</p>
}
</section>
}
@if (reportShown && selectBy === 'accounts') {
<section class="bkd-report-out">
<h4 class="bkd-h4 bkd-report-title">
{{ picked.length }} {{ picked.length === 1 ? 'account' : 'accounts' }}
<small>as on {{ asOnIso }} &middot; Amounts in {{ unitWord }}</small>
</h4>
@if (reportLoading) {
<p class="bkd-days-note">Loading…</p>
}
@if (reportError) {
<p class="bkd-warn">{{ reportError }}</p>
}
@if (report; as rep) {
<table class="bkd-tbl bkd-focus-tbl">
<thead>
<tr>
<th>Bucket</th>
<th>Days</th>
<th>Dates</th>
<th class="bkd-num">Amount</th>
<th class="bkd-num">%</th>
<th class="bkd-num">Entries</th>
</tr>
</thead>
<tbody>
@for (r of reportBuckets; track r.label) {
<tr [class.bkd-dim]="!r.amount">
<td class="bkd-b-name">{{ r.label }}</td>
<td>{{ r.days }}</td>
<td>{{ r.dates }}</td>
<td class="bkd-num">{{ amountText(r.amount) }}</td>
<td class="bkd-num">{{ r.amount ? (r.pct | number: '1.0-1') + '%' : '' }}</td>
<td class="bkd-num">{{ r.count || '' }}</td>
</tr>
}
</tbody>
<tfoot>
<tr>
<td colspan="3">Total</td>
<td class="bkd-num">{{ amountText(rep.total) }}</td>
<td class="bkd-num">{{ rep.total ? '100%' : '' }}</td>
<td class="bkd-num">{{ rep.count }}</td>
</tr>
</tfoot>
</table>
<p class="bkd-days-note">
A deposit counts once, on its maturity date; a loan counts once for each unpaid instalment, on its due
date. Anything already due goes in the first bucket (a loan instalment follows the Overdue rule).
Deposit amounts are balances, loan amounts are the principal of each instalment; interest is a separate
line of the statement.
</p>
<h4 class="bkd-h4">Each entry</h4>
<div class="bkd-acc-wrap">
<table class="bkd-tbl bkd-acc">
<thead>
<tr>
<th>Account</th>
<th>Customer</th>
<th>Product</th>
<th>Due / maturity date</th>
<th class="bkd-num">Days</th>
<th>Bucket</th>
<th>Status</th>
<th class="bkd-num">Amount</th>
</tr>
</thead>
<tbody>
@for (r of rep.rows; track $index) {
<tr>
<td class="bkd-b-name">
{{ r.kind }} {{ r.accountNo }}
@if (r.installment) {
<span class="bkd-b-hint">instalment {{ r.installment }}</span>
}
</td>
<td>{{ r.customer }}</td>
<td>
{{ r.product }}
@if (r.note) {
<div class="bkd-warn">{{ r.note }}</div>
}
</td>
<td>{{ r.date ? (r.date | date: 'dd-MMM-yyyy') : '—' }}</td>
<td class="bkd-num">{{ r.days === null ? '' : r.days }}</td>
<td>{{ bucketName(r.bucket) }}</td>
<td>{{ r.status }}</td>
<td class="bkd-num">{{ amountText(r.amount) }}</td>
</tr>
} @empty {
<tr>
<td colspan="8" class="bkd-empty">Nothing to show for these accounts on this date.</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
}
</section>
} @else {
<section class="bkd-setup">
<div class="bkd-range-head">
<h4 class="bkd-h4 bkd-h4-first">Bucket ranges</h4>
<button type="button" class="bkd-btn bkd-btn-sm" [class.bkd-btn-on]="rangeEdit" (click)="toggleRangeEdit()">
{{ rangeEdit ? 'Done' : 'Edit' }}
</button>
</div>
<p class="bkd-days-intro">
@if (rangeEdit) {
Change the Start / End of a bucket, or add and remove buckets. When you change the End of a bucket, the
Start of the next one and its heading change with it.
} @else {
Start and End of each bucket. Click Edit to change, add or remove buckets.
}
</p>
<table class="bkd-tbl bkd-range">
<thead>
<tr>
<th>#</th>
<th>Start</th>
<th>End</th>
<th>Heading</th>
<th>Days / Dates</th>
@if (rangeEdit) {
<th></th>
}
</tr>
</thead>
<tbody>
@for (b of cfg.buckets; track $index) {
<tr>
<td class="bkd-range-n">{{ $index + 1 }}</td>
<td>
@if (fromOf($index); as f) {
@if (rangeEdit) {
<span class="bkd-span-edit">
<input
type="number"
min="1"
[ngModel]="f.n"
(ngModelChange)="setFrom($index, { n: $event })"
aria-label="Start"
/>
<select
[ngModel]="f.unit"
(ngModelChange)="setFrom($index, { unit: $event })"
aria-label="Start unit"
>
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
} @else {
<span class="bkd-range-val">{{ f.n }} {{ unitLetter(f.unit) }}</span>
}
}
</td>
<td>
@if (b.upTo; as t) {
@if (rangeEdit) {
<span class="bkd-span-edit">
<input
type="number"
min="1"
[ngModel]="t.n"
(ngModelChange)="setTo($index, { n: $event })"
aria-label="End"
/>
<select
[ngModel]="t.unit"
(ngModelChange)="setTo($index, { unit: $event })"
aria-label="End unit"
>
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
} @else {
<span class="bkd-range-val">{{ t.n }} {{ unitLetter(t.unit) }}</span>
}
} @else {
<span class="bkd-none">end</span>
}
</td>
<td class="bkd-b-name">
{{ b.label }}
@if (rangeProblem($index); as problem) {
<div class="bkd-warn">{{ problem }}</div>
}
</td>
<td class="bkd-b-hint">
@if (windows[$index]; as w) {
{{ dayRange(w) }} &middot; {{ dateRange(w) }}
}
</td>
@if (rangeEdit) {
<td>
@if (cfg.buckets.length > 2) {
<button
type="button"
class="bkd-x"
(click)="removeBucket($index)"
matTooltip="Remove this bucket"
aria-label="Remove bucket"
>
&#10005;
</button>
}
</td>
}
</tr>
}
</tbody>
</table>
@if (rangeEdit) {
<!-- A bucket the rules did not have yesterday: add it here, it lands in date order -->
<div class="bkd-addbucket">
<span class="bkd-addbucket-title">+ Add a bucket ending at:</span>
<span class="bkd-span-edit">
<input type="number" min="1" [(ngModel)]="newEnd.n" aria-label="End of the new bucket" />
<select [(ngModel)]="newEnd.unit" aria-label="Unit of the new bucket">
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
</span>
<button type="button" class="bkd-btn bkd-btn-sm" (click)="addBucket()">Add</button>
<span class="bkd-b-hint">
It is placed in the right position automatically and splits the bucket it falls inside into two.
</span>
</div>
}
<div class="bkd-calc">
<label>
<span>Check a maturity date</span>
<input type="date" [(ngModel)]="maturityIso" aria-label="Maturity date" />
</label>
@if (dayCheck; as c) {
<div class="bkd-calc-out">
@if (c.due) {
<strong>Already due</strong> ({{ -c.days }} days ago) &rarr; <strong>{{ c.bucket }}</strong>
} @else {
<strong>{{ c.days }} days left</strong> &rarr; <strong>{{ c.bucket }}</strong>
}
</div>
} @else {
<div class="bkd-calc-out bkd-none">Pick a maturity date and its bucket will show here.</div>
}
</div>
</section>
<!-- ═══ The configuration table, laid out like the filed statement ═══ -->
@for (sec of sections; track sec.side) {
<div class="bkd-sheet-wrap">
<table class="bkd-sheet">
<thead>
<tr>
<th colspan="3" rowspan="2" class="s-title">{{ sec.title }}</th>
<th [attr.colspan]="cfg.buckets.length" class="s-span">RESIDUAL MATURITY</th>
<th rowspan="2" class="s-total-h">Total</th>
@if (editMode) {
<th rowspan="2" class="s-tools-h">Line</th>
}
</tr>
<tr>
@for (b of cfg.buckets; track $index) {
<th class="s-bucket">{{ b.label }}</th>
}
</tr>
</thead>
<tbody>
@for (item of itemsOf(sec.side); track item.id) {
@if (item.header) {
<tr class="s-head">
<td class="s-ref">
@if (editMode) {
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
} @else {
{{ item.ref }}
}
</td>
<td colspan="2" class="s-name">
@if (editMode) {
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
} @else {
{{ item.name }}
}
</td>
<td [attr.colspan]="cfg.buckets.length + 1" class="s-head-fill"></td>
@if (editMode) {
<td class="s-tools">
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
&#10005;
</button>
</td>
}
</tr>
} @else {
<tr [class.s-off]="item.included === false">
<td class="s-ref">
@if (editMode) {
<input type="text" [(ngModel)]="item.ref" (ngModelChange)="changed()" aria-label="Number" />
} @else {
{{ item.ref }}
}
</td>
<td
class="s-name"
[class.s-sub]="item.level === 1"
[matTooltip]="item.reason"
matTooltipPosition="above"
>
@if (editMode) {
<input type="text" [(ngModel)]="item.name" (ngModelChange)="changed()" aria-label="Naam" />
} @else {
{{ item.name }}
}
</td>
<td class="s-rule">
<select
[ngModel]="item.rule"
(ngModelChange)="ruleChanged(item, $event)"
aria-label="Rule"
[matTooltip]="item.reason"
>
@for (r of ruleOptions; track r.value) {
<option [value]="r.value">{{ r.label }}</option>
}
</select>
@if (item.rule === 'maturity' || item.rule === 'behavioural') {
<!-- Which accounts fill a due-date line, or give a percentage-split line its total -->
<select
class="s-src"
[(ngModel)]="item.source"
(ngModelChange)="changed()"
aria-label="Which accounts the amount comes from"
matTooltip="Which accounts the amount of this line comes from"
>
@for (o of sourceOptions; track o.value) {
<option [value]="o.value">{{ o.label }}</option>
}
</select>
}
</td>
@if (item.rule === 'maturity') {
@if (autoAmounts(item); as amounts) {
<!-- Filled by the accounts themselves, each in the bucket of its own date -->
@for (v of amounts; track $index) {
<td class="s-auto" [class.s-auto-zero]="!v">{{ amountText(v) }}</td>
}
<td class="s-tot s-auto-total" [matTooltip]="autoAccounts(item) + ' accounts'">
{{ amountText(autoTotal(amounts)) }}
</td>
} @else {
<td [attr.colspan]="cfg.buckets.length" class="s-due">
@if (!item.source) {
This line has to be filled by hand: it has no account source yet
} @else if (loading) {
Data aa raha hai…
} @else {
No data came from the accounts
}
</td>
<td class="s-tot">&mdash;</td>
}
} @else if (item.rule === 'fixed') {
<!-- A fixed line sits in one bucket: click the bucket it should move to -->
@for (b of cfg.buckets; track $index) {
<td
class="s-cell s-pick"
[class.s-cell-on]="share(item, $index) !== null"
role="button"
tabindex="0"
[attr.aria-label]="'Put it in ' + b.label"
[matTooltip]="share(item, $index) !== null ? b.label : 'Yahan le jaayein: ' + b.label"
(click)="setFixed(item, $index)"
(keydown.enter)="setFixed(item, $index)"
(keydown.space)="setFixed(item, $index); $event.preventDefault()"
>
{{ share(item, $index) !== null ? '100%' : '' }}
</td>
}
} @else {
@for (b of cfg.buckets; track $index) {
<td class="s-cell s-cell-input" [class.s-cell-on]="share(item, $index) !== null">
<input
type="number"
min="0"
max="100"
step="any"
placeholder="%"
[ngModel]="share(item, $index)"
(ngModelChange)="setShare(item, $index, $event)"
[attr.aria-label]="b.label"
/>
</td>
}
<td
class="s-tot"
[class.s-tot-ok]="rowState(item) === 'ok'"
[class.s-tot-bad]="rowState(item) === 'bad'"
[class.s-tot-todo]="rowState(item) === 'todo'"
>
{{ rowState(item) === 'todo' ? 'fill in' : rowTotal(item) + '%' }}
</td>
}
@if (editMode) {
<td class="s-tools">
<label class="s-incl" matTooltip="Show in report">
<input
type="checkbox"
[ngModel]="item.included !== false"
(ngModelChange)="item.included = $event; changed()"
aria-label="In report"
/>
</label>
<button type="button" class="bkd-x" (click)="removeLine(item)" aria-label="Hatayein">
&#10005;
</button>
</td>
}
</tr>
}
}
</tbody>
</table>
@if (editMode) {
<button type="button" class="bkd-btn bkd-btn-sm s-add" (click)="addLine(sec.side)">
+ Add {{ sec.title === 'OUTFLOWS' ? 'an Outflow' : 'an Inflow' }} line
</button>
}
</div>
}
@if (editMode) {
<div class="bkd-bucket-edit">
<div class="bkd-editor-sub">Bucket names and day span</div>
@for (b of cfg.buckets; track $index) {
<div class="bkd-bucket-edit-row">
<span class="bkd-step-n">{{ $index + 1 }}</span>
<input type="text" [(ngModel)]="b.label" (ngModelChange)="changed()" aria-label="Bucket name" />
<input type="text" [(ngModel)]="b.hint" (ngModelChange)="changed()" aria-label="Bucket description" />
<div class="bkd-span">
@if (b.upTo) {
<input
type="number"
min="1"
[(ngModel)]="b.upTo.n"
(ngModelChange)="bucketsChanged()"
aria-label="How far it reaches"
/>
<select [(ngModel)]="b.upTo.unit" (ngModelChange)="bucketsChanged()" aria-label="Unit">
<option value="day">d</option>
<option value="month">m</option>
<option value="year">y</option>
</select>
} @else {
<span class="bkd-none">No end</span>
}
<label class="bkd-check">
<input
type="checkbox"
[ngModel]="!b.upTo"
(ngModelChange)="setOpenEnded(b, $event)"
aria-label="No end"
/>
No end
</label>
</div>
</div>
}
@if (cfg.overdue; as o) {
<div class="bkd-editor-sub bkd-overdue-title">Overdue (due but not received): which bucket</div>
<div class="bkd-overdue">
<label>
<span>Overdue less than a month</span>
<select [(ngModel)]="o.recent" (ngModelChange)="bucketsChanged()" aria-label="Recent overdue bucket">
@for (b of cfg.buckets; track $index) {
<option [ngValue]="$index">{{ b.label }}</option>
}
</select>
</label>
<label>
<span>Overdue more than a month</span>
<select [(ngModel)]="o.old" (ngModelChange)="bucketsChanged()" aria-label="Old overdue bucket">
@for (b of cfg.buckets; track $index) {
<option [ngValue]="$index">{{ b.label }}</option>
}
</select>
</label>
</div>
}
</div>
}
<!-- What the accounts could not put in a date bucket, so nothing is silently left out -->
@if (breakdown; as b) {
<section class="bkd-apart-box">
<h4 class="bkd-h4">Shown separately (not slotted by date)</h4>
<ul class="bkd-apart">
@for (n of b.notSlotted; track n.key) {
<li>
<strong>{{ n.label }}</strong> &mdash; {{ n.accounts }} loan, outstanding
{{ amountText(n.outstanding) }}
<div class="bkd-b-hint">{{ n.why }}</div>
</li>
}
</ul>
<ul class="bkd-notes">
@for (note of b.notes; track note) {
<li>{{ note }}</li>
}
</ul>
</section>
}
<!-- ═══ What the report will show ═══ -->
<section class="bkd-report">
<h3 class="bkd-h3">What the report will show</h3>
<div class="bkd-report-card">
<div class="r-title">Statement of Structural Liquidity as on {{ asOnIso }}</div>
<div class="r-sub">( Amounts in {{ unitWord }} )</div>
</div>
<ul class="bkd-report-list">
<li>
Two tables, <strong>OUTFLOWS</strong> and <strong>INFLOWS</strong>, with the lines above. Each line’s amount
sits in its bucket, with a <strong>Total</strong> in the last column.
</li>
<li><strong>A. TOTAL OUTFLOWS</strong> &mdash; the sum of all outflow lines, bucket by bucket.</li>
<li><strong>B. TOTAL INFLOWS</strong> &mdash; the sum of all inflow lines, bucket by bucket.</li>
<li><strong>C. MISMATCH (B − A)</strong> &mdash; inflow minus outflow, in each bucket.</li>
<li>
<strong>D. CUMULATIVE MISMATCH</strong> &mdash; running total of C, from the first bucket up to that bucket.
</li>
<li><strong>E. C as % to A</strong> &mdash; mismatch divided by outflow, times 100.</li>
@for (l of cfg.limits; track $index) {
<li class="bkd-limit">
<strong>{{ cfg.buckets[l.bucket]?.label }}</strong
>: negative mismatch must not exceed {{ l.pct }}%. The report shows it in red when it does.
</li>
}
</ul>
<p class="bkd-days-note">
Where the amount comes from: <strong>By due date</strong> lines take their data from FD, RD and Loan accounts.
<strong>Fixed</strong>, <strong>Split</strong> and <strong>By study</strong> lines take the ledger (GL)
balance and apply the % above. Lines with no register have to be filled in by hand.
</p>
</section>
}
</div>
</div>
@@ -0,0 +1,962 @@
/**
* 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, DestroyRef, inject } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Subject, catchError, debounceTime, of, switchMap } from 'rxjs';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { businessTodayIso } from 'app/core/utils/business-today';
import {
AlmBucket,
AlmConfig,
AlmItem,
AlmRule,
AlmSide,
AlmPlacement,
AlmSource,
AlmSpan,
AlmUnit,
RULE_OPTIONS,
SHARE_SOURCES,
SOURCE_OPTIONS,
defaultConfig
} from './alm-bucket-breakdown.config';
import {
BucketWindow,
addSpan,
bucketIndexFor,
bucketLabel,
bucketWindows,
dayRangeText,
daysBetween,
formatDate,
fromSpanOf,
parseIsoDate,
setFromSpan
} from './alm-bucket.util';
import { AccountHit, AccountRow, AlmMaturityService, MaturityBreakdown, SelectedReport } from './alm-maturity.service';
type AmountUnit = 'rupee' | 'lakh' | 'crore';
/** How a line's shares stand: whether the bank still has something to fill in. */
type RowState = 'spread' | 'ok' | 'todo' | 'bad';
/** Which of the server's account groups each source adds together. */
const SOURCE_GROUPS: Record<'FD_RD' | 'FD' | 'RD' | 'LOAN', string[]> = {
FD_RD: [
'FD',
'RD'
],
FD: ['FD'],
RD: ['RD'],
LOAN: ['LOAN']
};
/** The two tables of the statement, in the order it prints them. */
const SECTIONS: { side: AlmSide; title: string }[] = [
{ side: 'liability', title: 'OUTFLOWS' },
{ side: 'asset', title: 'INFLOWS' }
];
/**
* Where the browser keeps the bank's edits. Same arrangement as the OSS GL mapping: there is no
* backend table for this yet, so the setting belongs to the browser it was made in. The suffix is
* the shape of what is stored; a different shape starts again from RBI's defaults.
*/
const STORAGE_KEY = 'alm_bucket_breakdown_config_v2';
/**
* ALM Time-Bucket Breakdown — the configuration of the Structural Liquidity Statement.
*
* One table laid out like the statement the bank files: the same numbered Outflow and Inflow lines
* down the side, the same residual-maturity buckets across the top. Each line says how its balance
* is slotted — wholly in one bucket, by the due date of each account, or by percentages the bank
* sets. The statement itself is computed from this configuration.
*
* Rules start from RBI's Urban Co-operative Bank defaults but none is fixed: every line, bucket and
* percentage can be edited, and "Reset" returns to RBI's.
*/
@Component({
selector: 'mifosx-alm-bucket-breakdown-dialog',
standalone: true,
templateUrl: './alm-bucket-breakdown-dialog.component.html',
styleUrls: ['./alm-bucket-breakdown-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatTooltipModule,
FaIconComponent
]
})
export class AlmBucketBreakdownDialogComponent {
private dialogRef = inject(MatDialogRef<AlmBucketBreakdownDialogComponent>);
private service = inject(AlmMaturityService);
private destroyRef = inject(DestroyRef);
readonly ruleOptions = RULE_OPTIONS;
readonly sourceOptions = SOURCE_OPTIONS;
readonly sections = SECTIONS;
cfg: AlmConfig = this.load();
/** Which tab is open: the day breakdown of one product, or the statement's configuration table. */
view: 'days' | 'config' = 'days';
/** Lines can be renamed, added, removed and left out of the report only while this is on. */
editMode = false;
/** The date the buckets are counted from — the business date, not the browser's clock. */
asOnIso = businessTodayIso();
/** Maturity typed into the day calculator. */
maturityIso = '';
/** Amounts come back in rupees; the bank reads them in lakhs unless told otherwise. */
readonly units: { value: AmountUnit; label: string; divisor: number }[] = [
{ value: 'rupee', label: '₹ (rupaye)', divisor: 1 },
{ value: 'lakh', label: 'Lakh', divisor: 100_000 },
{ value: 'crore', label: 'Crore', divisor: 10_000_000 }
];
unit: AmountUnit = 'lakh';
/** FD / RD / loan totals per bucket for the as-on date, from the server. */
breakdown: MaturityBreakdown | null = null;
loading = false;
loadError = '';
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
private loadSeq = 0;
constructor() {
this.watchSearch();
this.loadBreakdown();
}
// ── The configuration table ──
/** The lines of one table, headings included, in statement order. */
itemsOf(side: AlmSide): AlmItem[] {
return this.cfg.items.filter((i) => i.side === side);
}
/** Columns across a table: reference, line, rule, every bucket, total, and the row tools while editing. */
get columnCount(): number {
return 3 + this.cfg.buckets.length + 1 + (this.editMode ? 1 : 0);
}
/** Share of a line's balance that goes to one bucket, or null when none does. */
share(item: AlmItem, bucket: number): number | null {
const placed = item.placements.filter((p) => p.bucket === bucket);
if (!placed.length) {
return null;
}
const explicit = placed.filter((p) => p.pct !== null && p.pct !== undefined && `${p.pct}` !== '');
if (explicit.length) {
return explicit.reduce((sum, p) => sum + Number(p.pct), 0);
}
// No share written down: a line with one bucket puts the whole balance there.
return item.placements.length === 1 ? 100 : null;
}
/** Set the share going to one bucket; empty or zero takes the bucket out of the line. */
setShare(item: AlmItem, bucket: number, raw: number | string | null): void {
const value = raw === null || raw === '' ? 0 : Math.max(0, Math.min(100, Number(raw)));
const rest = item.placements.filter((p) => p.bucket !== bucket);
if (value > 0 && !Number.isNaN(value)) {
const kept = item.placements.find((p) => p.bucket === bucket);
rest.push({ bucket, pct: value, label: kept?.label });
}
item.placements = rest.sort((a, b) => a.bucket - b.bucket);
this.changed();
}
/**
* The amounts a due-date line gets from its accounts, in rupees; null while there is nothing to
* show (no source chosen, not loaded yet, or the server could not answer).
*/
autoAmounts(item: AlmItem): number[] | null {
return item.rule === 'maturity' ? this.itemAmounts(item) : null;
}
/**
* What a line has in each bucket, in rupees. Deposits and term loans come bucket by bucket from
* their own dates; cash-credit and NPA loans come as one outstanding figure that the line's own
* percentages divide. Null when the line has no accounts behind it, or nothing has loaded yet.
*/
itemAmounts(item: AlmItem | null): number[] | null {
return item?.source ? this.amountsOf(item.source, item) : null;
}
/**
* What one kind of account has in each bucket. For cash-credit and NPA loans the percentages come
* from the statement line that names that kind, and are zero until that line has them.
*/
private amountsOf(source: AlmSource, line: AlmItem | null): number[] | null {
if (!this.breakdown) {
return null;
}
if (SHARE_SOURCES.includes(source)) {
const outstanding = this.apartLine(source)?.outstanding ?? 0;
return this.cfg.buckets.map((_b, i) => (outstanding * (line ? (this.share(line, i) ?? 0) : 0)) / 100);
}
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS];
const groups = this.breakdown.groups.filter((g) => keys?.includes(g.key));
if (!groups.length) {
return null;
}
return this.cfg.buckets.map((_b, i) => groups.reduce((sum, g) => sum + (g.buckets[i] ?? 0), 0));
}
/** The loans the server reports apart from the date buckets: CC/OD, or NPA. */
private apartLine(source: AlmSource): { outstanding: number; accounts: number } | undefined {
const key = source === 'CCOD' ? 'REVOLVING' : 'NPA';
return this.breakdown?.notSlotted.find((n) => n.key === key);
}
/** Accounts behind a line, for the tooltip and the day view's total. */
autoAccounts(item: AlmItem | null): number {
const source = item?.source;
if (!source || !this.breakdown) {
return 0;
}
if (SHARE_SOURCES.includes(source)) {
return this.apartLine(source)?.accounts ?? 0;
}
const keys = SOURCE_GROUPS[source as keyof typeof SOURCE_GROUPS] ?? [];
return this.breakdown.groups.filter((g) => keys.includes(g.key)).reduce((sum, g) => sum + g.accounts, 0);
}
autoTotal(amounts: number[]): number {
return amounts.reduce((sum, v) => sum + v, 0);
}
// ── Picking kinds of account ──
/** Whether the day-wise breakdown is by kind of account, or for individual accounts found by search. */
selectBy: 'types' | 'accounts' = 'types';
/** The kinds of account that can be picked, on the liability and the asset side. */
readonly pickTypes: { value: AlmSource; label: string; side: AlmSide }[] = [
{ value: 'FD', label: 'Fixed Deposits (FD)', side: 'liability' },
{ value: 'RD', label: 'Recurring Deposits (RD)', side: 'liability' },
{ value: 'LOAN', label: 'Term Loans', side: 'asset' },
{ value: 'CCOD', label: 'CC / OD Loans', side: 'asset' },
{ value: 'NPA', label: 'NPA Loans', side: 'asset' }
];
/** The kinds picked; any number of them, from either side. */
pickedTypes: AlmSource[] = [
'FD',
'RD'
];
get pickGroups(): { side: AlmSide; title: string; items: { value: AlmSource; label: string; side: AlmSide }[] }[] {
return [
{
side: 'liability',
title: 'Liabilities: deposits (customers)',
items: this.pickTypes.filter((t) => t.side === 'liability')
},
{ side: 'asset', title: 'Assets: loans (customers)', items: this.pickTypes.filter((t) => t.side === 'asset') }
];
}
setSelectBy(mode: 'types' | 'accounts'): void {
this.selectBy = mode;
this.reportShown = false;
}
isTypePicked(value: AlmSource): boolean {
return this.pickedTypes.includes(value);
}
/** Tick or untick a kind. Any number can be ticked together; a report already open is put away. */
toggleType(value: AlmSource): void {
this.pickedTypes = this.isTypePicked(value) ? this.pickedTypes.filter((v) => v !== value) : [
...this.pickedTypes,
value
];
this.reportShown = false;
}
selectAllTypes(): void {
this.pickedTypes = this.pickTypes.map((t) => t.value);
this.reportShown = false;
}
clearTypes(): void {
this.pickedTypes = [];
this.reportShown = false;
}
/** One column per kind picked: what it has in every bucket, and a note where something is still to be filled in. */
get typeColumns(): {
value: AlmSource;
label: string;
side: AlmSide;
amounts: number[];
total: number;
notice: string;
}[] {
return this.pickTypes
.filter((t) => this.isTypePicked(t.value))
.map((t) => {
const amounts =
this.amountsOf(t.value, this.cfg.items.find((i) => !i.header && i.source === t.value) ?? null) ??
this.cfg.buckets.map(() => 0);
const total = this.autoTotal(amounts);
const outstanding = SHARE_SOURCES.includes(t.value) ? (this.apartLine(t.value)?.outstanding ?? 0) : 0;
const notice =
outstanding > 0 && total === 0
? `The bucket-wise % for this kind are not filled in yet, so nothing is divided. Fill them in the Configuration table (outstanding is ${this.amountText(outstanding)}).`
: '';
return { value: t.value, label: t.label, side: t.side, amounts, total, notice };
});
}
/** One row per bucket across the picked kinds, with the liability and asset sides added up separately. */
get typeMatrix(): { label: string; days: string; dates: string; cells: number[]; liab: number; asset: number }[] {
const cols = this.typeColumns;
return this.windows.map((w, i) => {
const cells = cols.map((c) => c.amounts[i] ?? 0);
return {
label: this.cfg.buckets[i]?.label ?? w.label,
days: dayRangeText(w),
dates: this.dateRange(w),
cells,
liab: cols.reduce((sum, c, k) => sum + (c.side === 'liability' ? cells[k] : 0), 0),
asset: cols.reduce((sum, c, k) => sum + (c.side === 'asset' ? cells[k] : 0), 0)
};
});
}
get liabilityGrandTotal(): number {
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'liability' ? c.total : 0), 0);
}
get assetGrandTotal(): number {
return this.typeColumns.reduce((sum, c) => sum + (c.side === 'asset' ? c.total : 0), 0);
}
get typeHasLiability(): boolean {
return this.typeColumns.some((c) => c.side === 'liability');
}
get typeHasAsset(): boolean {
return this.typeColumns.some((c) => c.side === 'asset');
}
// ── Picking accounts one by one ──
/** What is typed in the search box. */
searchText = '';
/** Which kind of account the search looks in: all, or one kind. */
searchKind: '' | 'FD' | 'RD' | 'LOAN' = '';
searchResults: AccountHit[] = [];
searching = false;
searchError = '';
/** The accounts picked so far. */
picked: AccountHit[] = [];
private searchTyped = new Subject<void>();
/** Whether the report of the picked accounts is on screen: only after Submit, and until the picks or the date change. */
reportShown = false;
report: SelectedReport | null = null;
reportLoading = false;
reportError = '';
/** Only the newest request's answer may land; a slow earlier one must not overwrite it. */
private reportSeq = 0;
/** Set up the search so it waits for typing to pause, and a slow answer to an old search never replaces a newer one. */
private watchSearch(): void {
this.searchTyped
.pipe(
debounceTime(300),
switchMap(() =>
this.service.searchAccounts(this.searchText.trim(), this.searchKind).pipe(
catchError((err) => {
this.searchError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'Search failed. The backend needs a restart with the new endpoint.';
return of({ results: [] as AccountHit[] });
})
)
),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((found) => {
this.searching = false;
this.searchResults = found.results;
});
}
/** Typing or changing the kind: search again once typing pauses. */
onSearchInput(): void {
this.searchError = '';
if (this.searchText.trim().length < 2) {
this.searchResults = [];
this.searching = false;
return;
}
this.searching = true;
this.searchTyped.next();
}
/** Whether an account is already picked. */
isPicked(hit: AccountHit): boolean {
return this.picked.some((p) => p.kind === hit.kind && p.id === hit.id);
}
pick(hit: AccountHit): void {
if (!this.isPicked(hit)) {
this.picked = [
...this.picked,
hit
];
this.reportShown = false;
}
}
unpick(hit: AccountHit): void {
this.picked = this.picked.filter((p) => !(p.kind === hit.kind && p.id === hit.id));
this.reportShown = false;
}
clearPicked(): void {
this.picked = [];
this.reportShown = false;
}
/** Show the day-wise breakdown of the picked accounts for the chosen date. */
submitReport(): void {
if (this.selectBy === 'types') {
if (this.pickedTypes.length) {
this.reportShown = true;
this.loadBreakdown();
}
return;
}
if (!this.picked.length) {
return;
}
this.reportShown = true;
this.loadReport();
}
/** Whether Submit has anything to submit. */
get canSubmit(): boolean {
return this.selectBy === 'types' ? this.pickedTypes.length > 0 : this.picked.length > 0;
}
/** Fetch the report of the picked accounts; also called again when the bucket setting changes under an open report. */
private loadReport(): void {
const seq = ++this.reportSeq;
this.reportError = '';
if (!parseIsoDate(this.asOnIso)) {
this.report = null;
this.reportError = 'The as-on date is not valid.';
return;
}
if (this.bucketSpecInvalid) {
this.report = null;
this.reportError = 'Only the last bucket may have no end; every other bucket needs an end.';
return;
}
this.reportLoading = true;
this.service
.getSelected(this.picked.map((p) => `${p.kind}:${p.id}`).join(','), {
asOnDate: this.asOnIso,
buckets: this.edgeSpec,
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
overdueOldBucket: this.cfg.overdue?.old ?? 2
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
if (seq === this.reportSeq) {
this.reportLoading = false;
this.report = result;
}
},
error: (err) => {
if (seq === this.reportSeq) {
this.reportLoading = false;
this.report = null;
this.reportError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'No data came back. The backend needs a restart with the new endpoint.';
}
}
});
}
/** A new as-on date means a new report: put the old one away until Submit is pressed again. */
onAsOnChange(): void {
this.reportShown = false;
this.loadBreakdown();
}
/** The report by bucket: what the picked accounts put in each one, with the days and dates of the bucket. */
get reportBuckets(): { label: string; days: string; dates: string; amount: number; count: number; pct: number }[] {
const rows = this.report?.rows ?? [];
const total = rows.reduce((sum, r) => sum + r.amount, 0);
return this.windows.map((w, i) => {
const inBucket = rows.filter((r) => r.bucket === i);
const amount = inBucket.reduce((sum, r) => sum + r.amount, 0);
return {
label: this.cfg.buckets[i]?.label ?? w.label,
days: dayRangeText(w),
dates: this.dateRange(w),
amount,
count: inBucket.length,
pct: total ? (amount / total) * 100 : 0
};
});
}
bucketName(index: number | null): string {
return index === null ? '' : (this.cfg.buckets[index]?.label ?? '');
}
// ── The bucket ranges, as a From / To table ──
/** Where a bucket starts, as the table writes it; null for the first, which always starts on day 1. */
fromOf(index: number): AlmSpan | null {
return fromSpanOf(this.cfg.buckets, index);
}
/** Change where a bucket starts. That is where the one before it ends, so that one moves too. */
setFrom(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
const current = this.fromOf(index);
if (!current) {
return;
}
const n = change.n !== undefined ? Number(change.n) : current.n;
this.applyRange(index - 1, () =>
setFromSpan(this.cfg.buckets, index, { n: n > 0 ? n : 1, unit: change.unit ?? current.unit })
);
}
/** Change where a bucket ends. */
setTo(index: number, change: { n?: number | string; unit?: AlmUnit }): void {
const current = this.cfg.buckets[index]?.upTo;
if (!current) {
return;
}
const n = change.n !== undefined ? Number(change.n) : current.n;
this.applyRange(index, () => {
this.cfg.buckets[index].upTo = { n: n > 0 ? n : 1, unit: change.unit ?? current.unit };
});
}
/**
* Apply a change to the end of one bucket, and keep the headings of the two buckets it touches in
* step — but only a heading still worded the way the table would word it; one the bank has typed
* over is left alone.
*/
private applyRange(endingIndex: number, apply: () => void): void {
const touched = [
endingIndex,
endingIndex + 1
].filter((i) => i >= 0 && i < this.cfg.buckets.length);
const wasGenerated = touched.map((i) => this.cfg.buckets[i].label === bucketLabel(this.cfg.buckets, i));
apply();
touched.forEach((i, k) => {
if (wasGenerated[k]) {
this.cfg.buckets[i].label = bucketLabel(this.cfg.buckets, i);
}
});
this.bucketsChanged();
}
/** The end typed into the "add a bucket" form. */
newEnd: { n: number; unit: AlmUnit } = { n: 7, unit: 'day' };
/** Start, End, adding and removing buckets work only while this is on, so a stray click cannot change them. */
rangeEdit = false;
toggleRangeEdit(): void {
this.rangeEdit = !this.rangeEdit;
}
/** 'd', 'm' or 'y' — a unit the way the range table writes it. */
unitLetter(unit: AlmUnit): string {
return unit === 'day' ? 'd' : unit === 'month' ? 'm' : 'y';
}
/**
* Add a bucket ending where the form says. It lands in date order and takes the front part of the
* bucket it falls inside, so what was placed there stays with the later part; lines, the overdue
* rule and limits keep pointing at the buckets they pointed at.
*/
addBucket(): void {
const end: AlmSpan = { n: Math.max(1, Number(this.newEnd.n) || 1), unit: this.newEnd.unit };
const buckets = this.cfg.buckets;
const reference = parseIsoDate(this.asOnIso) ?? new Date();
const endTime = addSpan(reference, end).getTime();
if (buckets.some((b) => b.upTo && addSpan(reference, b.upTo).getTime() === endTime)) {
window.alert('A bucket already ends there.');
return;
}
let position = buckets.findIndex((b) => !b.upTo || addSpan(reference, b.upTo).getTime() > endTime);
if (position < 0) {
position = buckets.length;
}
const laterWasGenerated = position < buckets.length && buckets[position].label === bucketLabel(buckets, position);
this.shiftBucketRefs((i) => (i >= position ? i + 1 : i));
// A bucket put in front becomes the first and starts on day 1; the old first now starts where it ends.
if (position === 0 && buckets[0]) {
buckets[0].startSpan = undefined;
}
buckets.splice(position, 0, { label: '', hint: '', upTo: end });
buckets[position].label = bucketLabel(buckets, position);
if (laterWasGenerated && position + 1 < buckets.length) {
buckets[position + 1].label = bucketLabel(buckets, position + 1);
}
this.bucketsChanged();
}
/**
* Remove a bucket. Whatever was placed in it moves to the next one (to the one before, if it was
* the last, which then runs on without end), so no line loses its money.
*/
removeBucket(index: number): void {
const buckets = this.cfg.buckets;
if (buckets.length <= 2) {
return;
}
if (!window.confirm(`Remove the "${buckets[index].label}" bucket? Its money will move to the next bucket.`)) {
return;
}
const last = index === buckets.length - 1;
const target = last ? index - 1 : index + 1;
const targetWasGenerated = buckets[target].label === bucketLabel(buckets, target);
this.shiftBucketRefs((i) => {
const moved = i === index ? target : i;
return moved > index ? moved - 1 : moved;
});
const removedStart = buckets[index].startSpan;
buckets.splice(index, 1);
// Removing the first bucket makes the next one the first, which keeps the statement's start.
if (index === 0 && removedStart) {
buckets[0].startSpan = removedStart;
}
if (last) {
buckets[index - 1].upTo = null;
}
const now = last ? index - 1 : index;
if (targetWasGenerated) {
buckets[now].label = bucketLabel(buckets, now);
}
this.mergeShares();
this.bucketsChanged();
}
/** Re-point everything that names a bucket by its position: lines, the overdue rule, the limits. */
private shiftBucketRefs(map: (index: number) => number): void {
this.cfg.items.forEach((item) => item.placements.forEach((p) => (p.bucket = map(p.bucket))));
if (this.cfg.overdue) {
this.cfg.overdue.recent = map(this.cfg.overdue.recent);
this.cfg.overdue.old = map(this.cfg.overdue.old);
}
this.cfg.limits.forEach((l) => (l.bucket = map(l.bucket)));
}
/** After two buckets became one, a line's two shares there become one share, and a limit is kept once. */
private mergeShares(): void {
this.cfg.items.forEach((item) => {
const merged = new Map<number, AlmPlacement>();
for (const p of item.placements) {
const have = merged.get(p.bucket);
if (!have) {
merged.set(p.bucket, { ...p });
} else if (have.pct != null || p.pct != null) {
have.pct = (Number(have.pct) || 0) + (Number(p.pct) || 0);
}
}
item.placements = [...merged.values()].sort((a, b) => a.bucket - b.bucket);
});
const seen = new Set<number>();
this.cfg.limits = this.cfg.limits.filter((l) => !seen.has(l.bucket) && !!seen.add(l.bucket));
}
/** A bucket that ends before it starts, or no later than the one before. Empty when the range is fine. */
rangeProblem(index: number): string {
const w = this.windows[index];
if (!w || !w.to) {
return '';
}
return w.to < w.from ? 'End is before Start: keep it later than the end of the previous bucket.' : '';
}
/** Move a whole line to one bucket: its balance all goes there and nowhere else. */
setFixed(item: AlmItem, bucket: number): void {
const label = item.placements.find((p) => p.bucket === bucket)?.label;
item.placements = [{ bucket, pct: 100, label }];
this.changed();
}
/** What a line's shares add up to, or null for a line that follows due dates. */
rowTotal(item: AlmItem): number | null {
if (item.rule === 'maturity') {
return null;
}
return this.cfg.buckets.reduce((sum, _b, i) => sum + (this.share(item, i) ?? 0), 0);
}
rowState(item: AlmItem): RowState {
const total = this.rowTotal(item);
if (total === null) {
return 'spread';
}
if (total === 0) {
return 'todo';
}
return Math.abs(total - 100) < 0.005 ? 'ok' : 'bad';
}
/** Shares that do not make up the whole balance, over every line that is in the report. */
get problemCount(): number {
return this.cfg.items.filter(
(i) => !i.header && i.included !== false && [
'bad',
'todo'
].includes(this.rowState(i))
).length;
}
/** Changing a line's rule: due-date lines follow each account's date, the rest carry shares. */
ruleChanged(item: AlmItem, rule: AlmRule): void {
item.rule = rule;
item.spreads = rule === 'maturity';
if (rule === 'fixed') {
// A fixed line sits in one bucket: keep the one that held the most, or start in the first.
const biggest = [...item.placements].sort((a, b) => (Number(b.pct) || 0) - (Number(a.pct) || 0))[0];
item.placements = [{ bucket: biggest?.bucket ?? 0, pct: 100, label: biggest?.label }];
}
this.changed();
}
// ── Editing the lines themselves ──
toggleEdit(): void {
this.editMode = !this.editMode;
}
addLine(side: AlmSide): void {
const items = this.itemsOf(side);
const last = items[items.length - 1];
this.cfg.items.splice(this.cfg.items.indexOf(last) + 1, 0, {
id: `u${Date.now()}`,
side,
ref: '',
level: 0,
name: side === 'liability' ? 'Nayi liability' : 'Nayi asset',
rule: 'fixed',
reason: '',
spreads: false,
placements: [{ bucket: 0, pct: 100 }]
});
this.changed();
}
removeLine(item: AlmItem): void {
if (!window.confirm(`"${item.name}" hata dein?`)) {
return;
}
this.cfg.items = this.cfg.items.filter((i) => i.id !== item.id);
this.changed();
}
/** Open-ended means "and beyond" — only the last bucket should be. */
setOpenEnded(bucket: AlmBucket, open: boolean): void {
bucket.upTo = open ? null : { n: 5, unit: 'year' };
this.bucketsChanged();
}
/** Called after any edit — the change is kept right away, there is no separate save step. */
changed(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(this.cfg));
} catch {
/* private window or blocked storage — the edit still holds for this session */
}
}
reset(): void {
if (!window.confirm('All settings will go back to the RBI defaults and your changes will be lost. Continue?')) {
return;
}
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
/* nothing stored to remove */
}
this.cfg = defaultConfig();
this.loadBreakdown();
}
close(): void {
this.dialogRef.close();
}
// ── Days from the as-on date ──
/** Every bucket as dates, for the chosen as-on date. */
get windows(): BucketWindow[] {
const asOn = parseIsoDate(this.asOnIso);
return asOn ? bucketWindows(asOn, this.cfg.buckets) : [];
}
/** Day-by-day text for a window: 'Din 1 se 14'. */
dayRange(w: BucketWindow): string {
return dayRangeText(w);
}
dateRange(w: BucketWindow): string {
return w.to ? `${formatDate(w.from)} to ${formatDate(w.to)}` : `${formatDate(w.from)} onwards`;
}
/** What the day calculator says for the maturity typed in; null until both dates are valid. */
get dayCheck(): { days: number; bucket: string; due: boolean } | null {
const asOn = parseIsoDate(this.asOnIso);
const maturity = parseIsoDate(this.maturityIso);
if (!asOn || !maturity) {
return null;
}
const days = daysBetween(asOn, maturity);
return {
days,
due: days <= 0,
bucket: this.cfg.buckets[bucketIndexFor(asOn, maturity, this.cfg.buckets)]?.label ?? ''
};
}
// ── Live FD / RD / loan totals for the as-on date ──
/** The bank's bucket edges as the server reads them: '14d,28d,3m,6m,1y,3y,5y'. */
private get edgeSpec(): string {
const letter = { day: 'd', month: 'm', year: 'y' };
return this.cfg.buckets
.filter((b) => b.upTo)
.map((b) => `${b.upTo?.n}${letter[b.upTo?.unit ?? 'day']}`)
.join(',');
}
/** Only the last bucket may be open-ended, and every other one needs an end. */
private get bucketSpecInvalid(): boolean {
const last = this.cfg.buckets.length - 1;
return this.cfg.buckets.some((b, i) => !b.upTo && i < last) || !!this.cfg.buckets[last]?.upTo;
}
/** Fetch the fixed-term breakdown for the chosen as-on date and the current bucket setting. */
loadBreakdown(): void {
const seq = ++this.loadSeq;
this.loadError = '';
if (!parseIsoDate(this.asOnIso)) {
this.breakdown = null;
this.loadError = 'The as-on date is not valid.';
return;
}
if (this.bucketSpecInvalid) {
this.breakdown = null;
this.loadError = 'Only the last bucket may have no end; every other bucket needs an end.';
return;
}
this.loading = true;
this.service
.getBreakdown({
asOnDate: this.asOnIso,
buckets: this.edgeSpec,
overdueRecentBucket: this.cfg.overdue?.recent ?? 0,
overdueOldBucket: this.cfg.overdue?.old ?? 2
})
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
if (seq !== this.loadSeq) {
return;
}
this.loading = false;
this.breakdown = result;
},
error: (err) => {
if (seq !== this.loadSeq) {
return;
}
this.loading = false;
this.breakdown = null;
this.loadError =
err?.error?.defaultUserMessage ||
err?.error?.developerMessage ||
'No data came back. The backend needs a restart with the new endpoint.';
}
});
}
/** A bucket edge or the overdue rule changed: the server must slot everything again. */
bucketsChanged(): void {
this.changed();
this.loadBreakdown();
if (this.reportShown && this.selectBy === 'accounts') {
this.loadReport();
}
}
/** The unit as the statement's heading words it: 'Lakhs of Rupees'. */
get unitWord(): string {
return { rupee: 'Rupees', lakh: 'Lakhs of Rupees', crore: 'Crores of Rupees' }[this.unit];
}
/** An amount in rupees as the chosen unit; a dash when there is nothing in it. */
amountText(rupees: number): string {
if (!rupees) {
return '–';
}
const divisor = this.units.find((u) => u.value === this.unit)?.divisor ?? 1;
return (rupees / divisor).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
/** The saved edits if they are intact, otherwise RBI's defaults. */
private load(): AlmConfig {
const fallback = defaultConfig();
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null') as AlmConfig | null;
if (
saved &&
Array.isArray(saved.buckets) &&
saved.buckets.length >= 2 &&
Array.isArray(saved.items) &&
Array.isArray(saved.limits)
) {
const overdue = (saved.overdue ??= fallback.overdue ?? { recent: 0, old: 2 });
// Buckets can be added and removed, so anything pointing past the last one is cut back.
const last = saved.buckets.length - 1;
overdue.recent = Math.min(overdue.recent, last);
overdue.old = Math.min(overdue.old, last);
saved.limits = saved.limits.filter((l) => l.bucket <= last);
saved.items.forEach((item) => {
item.placements = (item.placements ?? []).filter((p) => p.bucket <= last);
});
// Lines saved before due-date lines could name their accounts take the default's, by id.
saved.items.forEach((item) => {
if (item.source === undefined) {
item.source = fallback.items.find((d) => d.id === item.id)?.source ?? '';
}
});
return saved;
}
} catch {
/* unreadable — start from the defaults */
}
return fallback;
}
}
@@ -0,0 +1,463 @@
/**
* 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/.
*/
/**
* The Structural Liquidity Statement of an Urban Co-operative Bank (non-scheduled — 8 residual-maturity
* buckets, 1-14 days not split), laid out row for row like the statement the bank files: the same
* numbered Outflow and Inflow lines, the same buckets.
*
* Everything in this file is only the DEFAULT. It follows RBI's Urban Co-operative Banks (Asset
* Liability Management) circular of 17 September 2008 and the 2025 Directions, and where the bank's
* own filed statement slots a line differently (Interest Payable, Provisions, Other Liabilities) it
* follows the bank's statement. The screen works on a copy the bank can edit and falls back to these
* values on "Reset"; percentages in particular are a benchmark that a bank may replace with its own
* behavioural study approved by the Board / ALCO.
*/
export type AlmSide = 'liability' | 'asset';
/**
* How the bucket is decided.
* - fixed: always the same bucket(s).
* - maturity: follows the due date of each account/instalment, so it spreads over the buckets.
* - split: one balance divided between buckets by share.
* - behavioural: the bank's own past-data study decides.
*/
export type AlmRule = 'fixed' | 'maturity' | 'split' | 'behavioural';
/**
* Which accounts feed a due-date line on their own. Empty means nothing does yet and the line has to
* be filled by hand. FD_RD is the two deposit kinds added together, as RBI's "Term Deposits" head is.
*/
export type AlmSource = 'FD' | 'RD' | 'FD_RD' | 'LOAN' | 'CCOD' | 'NPA' | '';
export const SOURCE_OPTIONS: { value: AlmSource; label: string }[] = [
{ value: '', label: 'None (fill by hand)' },
{ value: 'FD_RD', label: 'FD + RD accounts (by maturity)' },
{ value: 'FD', label: 'FD accounts only (by maturity)' },
{ value: 'RD', label: 'RD accounts only (by maturity)' },
{ value: 'LOAN', label: 'Term Loan accounts (by instalment)' },
{ value: 'CCOD', label: 'CC / OD loan accounts (outstanding × %)' },
{ value: 'NPA', label: 'NPA loan accounts (outstanding × %)' }
];
/** Sources whose accounts give one outstanding figure that the line's own percentages then divide. */
export const SHARE_SOURCES: AlmSource[] = [
'CCOD',
'NPA'
];
export type AlmUnit = 'day' | 'month' | 'year';
/** How far out a bucket reaches, counted from the as-on date. */
export interface AlmSpan {
n: number;
unit: AlmUnit;
}
export interface AlmBucket {
/** The column heading, as the statement prints it. */
label: string;
/** One line a branch user can follow. */
hint: string;
/**
* Last day the bucket takes, counted from the as-on date. Calendar months and years are used
* as they are, so '3 months' from 30 June is 30 September. Null means no upper end.
*/
upTo: AlmSpan | null;
/**
* Only on the first bucket: where it starts, when that is not day 1. Every later bucket starts where
* the one before it ends. Money due before this start still counts in the first bucket, since
* nothing may fall outside the statement.
*/
startSpan?: AlmSpan;
}
export interface AlmPlacement {
/** Index into the bucket list. */
bucket: number;
/** Share of the balance that goes to this bucket, 0-100; blank when the whole line goes there. */
pct?: number | null;
/** Short name for the portion, e.g. 'Core'. */
label?: string;
}
export interface AlmItem {
id: string;
side: AlmSide;
/** The statement's own numbering: '1.', '3.', 'I)'. */
ref: string;
/** 0 for a numbered head, 1 for a line under a head. */
level: 0 | 1;
/** A heading only, such as '3. Deposits': it carries no figure of its own. */
header?: boolean;
/** Left out of the report when false; shown when absent or true. */
included?: boolean;
name: string;
rule: AlmRule;
/** Plain-language reason, in the words a branch manager would use. */
reason: string;
/** True when each account falls in the bucket of its own due date. */
spreads: boolean;
/** For a due-date line: the accounts whose maturities fill it. */
source?: AlmSource;
placements: AlmPlacement[];
}
/** Prudential cap on the net cumulative negative mismatch, as a % of cumulative outflow in that bucket. */
export interface AlmLimit {
bucket: number;
pct: number;
}
/**
* Where an instalment or receivable that is already due and unpaid is slotted — the bank's Overdue
* Receivables rule. Both are bucket positions: 'recent' for one that fell due less than a month ago,
* 'old' for anything older.
*/
export interface AlmOverdueRule {
recent: number;
old: number;
}
export interface AlmConfig {
buckets: AlmBucket[];
items: AlmItem[];
limits: AlmLimit[];
overdue?: AlmOverdueRule;
}
export const RULE_OPTIONS: { value: AlmRule; label: string; help: string }[] = [
{ value: 'fixed', label: 'Fixed', help: 'Always in the same bucket' },
{ value: 'maturity', label: 'By due date', help: 'Each account by its own maturity' },
{ value: 'split', label: 'Split', help: 'Balance divided by percentages' },
{ value: 'behavioural', label: 'By study', help: 'Percentages from the bank’s own study' }
];
/** Column headings as the filed statement prints them, with the span each one covers. */
export const DEFAULT_BUCKETS: AlmBucket[] = [
{ label: '1 to 14 days', hint: 'Needed straight away', upTo: { n: 14, unit: 'day' } },
{ label: '15 to 28 days', hint: 'After the next two weeks', upTo: { n: 28, unit: 'day' } },
{ label: '29 days and upto 3 months', hint: 'Within this quarter', upTo: { n: 3, unit: 'month' } },
{ label: 'Over 3 months and upto 6 months', hint: 'Next quarter', upTo: { n: 6, unit: 'month' } },
{ label: 'Over 6 months and upto 1 year', hint: 'Within the year', upTo: { n: 1, unit: 'year' } },
{ label: 'Over 1 year and upto 3 years', hint: 'Medium term', upTo: { n: 3, unit: 'year' } },
{ label: 'Over 3 years and upto 5 years', hint: 'Long term', upTo: { n: 5, unit: 'year' } },
{ label: 'Over 5 years', hint: 'Permanent items', upTo: null }
];
/** Non-scheduled UCB: negative mismatch in 1-14 and 15-28 days must stay within 20% of outflows. */
export const DEFAULT_LIMITS: AlmLimit[] = [
{ bucket: 0, pct: 20 },
{ bucket: 1, pct: 20 }
];
/** Buckets by position, so the lines below read as the bucket they mean. */
const B = { d14: 0, d28: 1, m3: 2, m6: 3, y1: 4, y3: 5, y5: 6, over5: 7 };
const whole = (bucket: number): AlmPlacement[] => [{ bucket, pct: 100 }];
type Seed = Omit<AlmItem, 'id' | 'spreads' | 'level'> & { level?: 0 | 1; spreads?: boolean };
const head = (side: AlmSide, ref: string, name: string): Seed => ({
side,
ref,
level: 0,
header: true,
name,
rule: 'fixed',
reason: '',
placements: []
});
const line = (
side: AlmSide,
ref: string,
name: string,
rule: AlmRule,
reason: string,
placements: AlmPlacement[] = [],
level: 0 | 1 = 1,
source: AlmSource = ''
): Seed => ({ side, ref, level, name, rule, reason, placements, spreads: rule === 'maturity', source });
const SEED: Seed[] = [
// ── OUTFLOWS (liabilities) ──
line(
'liability',
'1.',
'Capital',
'fixed',
'This is the bank’s own money and is never paid back, so it goes in the farthest bucket.',
whole(B.over5),
0
),
line(
'liability',
'2.',
'Reserves & Surplus',
'fixed',
'The bank’s own money, never paid back. Farthest bucket.',
whole(B.over5),
0
),
head('liability', '3.', 'Deposits'),
line(
'liability',
'I)',
'Current Deposits',
'split',
'A part can be withdrawn at any time (volatile), so it goes in 1-14 days. The rest is stable (core), so it goes in 1-3 years. Change the % from the bank’s own study.',
[
{ bucket: B.d14, pct: 10, label: 'Volatile' },
{ bucket: B.y3, pct: 90, label: 'Core' }
]
),
line(
'liability',
'II)',
'Saving Bank Deposits',
'split',
'Same as Current. RBI’s benchmark is about 10% volatile. Change the % from the bank’s own study.',
[
{ bucket: B.d14, pct: 10, label: 'Volatile' },
{ bucket: B.y3, pct: 90, label: 'Core' }
]
),
line(
'liability',
'III)',
'Term Deposits (FD and RD)',
'maturity',
'The maturity date of each FD and RD decides its bucket. An FD that is already due goes in the first bucket.',
[],
1,
'FD_RD'
),
line('liability', 'IV)', 'Certificates of Deposit', 'maturity', 'By maturity date.'),
head('liability', '4.', 'Borrowings'),
line('liability', 'I.', 'Call and short Notice', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'II.', 'Inter-Bank (Term)', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'III.', 'Refinances', 'maturity', 'The bucket of the day it has to be repaid.'),
line('liability', 'IV.', 'Others (specify)', 'maturity', 'The bucket of the day it has to be repaid.'),
head('liability', '5.', 'Other Liabilities & Provisions'),
line('liability', 'I.', 'DD / Bills Payable', 'fixed', 'These are paid quickly, so 1-14 days.', whole(B.d14)),
line('liability', 'II.', 'Branch Adjustments', 'fixed', 'Settled between branches, usually quickly.', whole(B.d14)),
line(
'liability',
'III.',
'Provisions',
'fixed',
'In your filed statement provisions are in 1-14 days. Change it according to the purpose of the provision.',
whole(B.d14)
),
line(
'liability',
'IV.',
'Other Liabilities',
'fixed',
'Amounts that are not to be paid out (like fees received in advance) go in the farthest bucket. Your filed statement has them in over 5 years.',
whole(B.over5)
),
line(
'liability',
'6.',
'Unavailed portion of Cash Credit / Overdraft / Demand Loan component of Working Capital',
'behavioural',
'How much of the limit customers will use comes from the bank’s past history. RBI: fill the % in the buckets within 12 months.',
[],
0
),
line(
'liability',
'7.',
'Letters of Credit / Guarantees',
'behavioural',
'How much of the LC / guarantee will devolve comes from the bank’s history, net of margin. Fill in the %.',
[],
0
),
line('liability', '8.', 'Repos', 'maturity', 'The bucket of the day it has to be repaid.', [], 0),
line('liability', '9.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
line('liability', '10.', 'Swaps (Sell/Buy/Maturing forwards)', 'maturity', 'By maturity date.', [], 0),
line(
'liability',
'11.',
'Interest Payable',
'fixed',
'Interest still to be paid as per the books. Your filed statement has it in 1-14 days. RBI: the bucket of the date it is due.',
whole(B.d14),
0
),
line(
'liability',
'12.',
'Others (specify)',
'fixed',
'Anything else. Your filed statement has it in over 5 years.',
whole(B.over5),
0
),
// ── INFLOWS (assets) ──
line(
'asset',
'1.',
'Cash',
'fixed',
'Cash is with the bank right now, so it is available straight away.',
whole(B.d14),
0
),
line(
'asset',
'2.',
'Balances with RBI',
'behavioural',
'Balance above the CRR/SLR requirement is in 1-14 days. The required part follows the maturity of the bank’s deposits (DTL). Fill in the %.',
[],
0
),
head('asset', '3.', 'Balances with other Banks'),
line(
'asset',
'I)',
'Current Account',
'fixed',
'What can be withdrawn goes in 1-14 days. RBI: the minimum balance that has to be kept goes in 1-3 years; split it if you want.',
whole(B.d14)
),
line(
'asset',
'II)',
'Money at Call and short Notice, Term Deposits and other placements with other banks including DCCBs and SCBs',
'maturity',
'By the maturity date of the FD / call money placed with the bank.'
),
line(
'asset',
'4.',
'Investments (including those under Repos but excluding Reverse Repos)',
'maturity',
'By the maturity of the bond / G-Sec, leaving out the part needed to maintain SLR.',
[],
0
),
head('asset', '5.', 'Advances (Performing)'),
line(
'asset',
'I.',
'Bills Purchased and Discounted (including bills under DUPN)',
'maturity',
'By the due date of the bill.'
),
line(
'asset',
'II.',
'Cash Credits, Overdrafts and Loans repayable on demand',
'behavioural',
'The part that comes back quickly goes in a short bucket; the part that always stays (core) goes in 1-3 years. Fill in the % from the bank’s study.',
[],
1,
'CCOD'
),
line(
'asset',
'III.',
'Term Loans',
'maturity',
'Each EMI goes in the bucket of its own due date, not the whole loan at once.',
[],
1,
'LOAN'
),
line(
'asset',
'6.',
'NPAs (Advances and Investments)',
'behavioural',
'Sub-standard NPA in 3-5 years; doubtful and loss in the farthest bucket (net of provision). Fill in the % as the bank sees fit.',
[],
0,
'NPA'
),
line(
'asset',
'7.',
'Fixed Assets',
'fixed',
'Things like buildings and furniture, which do not bring in money quickly.',
whole(B.over5),
0
),
head('asset', '8.', 'Other Assets'),
line(
'asset',
'I.',
'Branch Adjustments',
'fixed',
'Debit balance of branch adjustments, usually settled quickly.',
whole(B.d14)
),
line('asset', 'II.', 'Leased Assets', 'maturity', 'By the date of the lease’s interim cash flows.'),
line(
'asset',
'III.',
'Others',
'fixed',
'Things that are not real money (intangibles, non-cash) go in the farthest bucket. Your filed statement has them in over 5 years.',
whole(B.over5)
),
line('asset', '9.', 'Reverse Repos', 'maturity', 'The bucket of the day the money comes back.', [], 0),
line('asset', '10.', 'Swaps (Buy/Sell)/Maturing forwards', 'maturity', 'By maturity date.', [], 0),
line('asset', '11.', 'Bills Rediscounted (DUPN)', 'maturity', 'By the due date of the bill.', [], 0),
line(
'asset',
'12.',
'Interest Receivable',
'fixed',
'Interest still to be received. Your filed statement has it in over 5 years. RBI: the bucket of the date it is due.',
whole(B.over5),
0
),
line(
'asset',
'13.',
'Export Refinance from RBI',
'fixed',
'Limit that has not been used yet can be drawn straight away.',
whole(B.d14),
0
),
line(
'asset',
'14.',
'Others (specify)',
'behavioural',
'Anything else. Fill in the % as the bank’s study gives.',
[],
0
)
];
/** A fresh, independent copy of the RBI defaults — callers are free to mutate it. */
export function defaultConfig(): AlmConfig {
return {
buckets: DEFAULT_BUCKETS.map((b) => ({ ...b })),
limits: DEFAULT_LIMITS.map((l) => ({ ...l })),
// RBI (2008 UCB circular): under a month overdue is expected within 14 days; older goes to 29 days-3 months.
overdue: { recent: 0, old: 2 },
items: SEED.map((s, i) => ({
...s,
level: s.level ?? 1,
id: `d${i + 1}`,
spreads: s.spreads ?? false,
placements: s.placements.map((p) => ({ ...p }))
}))
};
}
@@ -0,0 +1,209 @@
/**
* 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 { AlmBucket, AlmSpan } from './alm-bucket-breakdown.config';
/**
* The day arithmetic behind the Structural Liquidity Statement: how many days a maturity is from the
* as-on date, and which bucket that lands it in. The statement and this screen both use it, so what
* the screen shows is what the return will do.
*
* Dates are plain calendar days ('yyyy-MM-dd'), never timestamps, so the browser's time zone and
* daylight saving cannot move a maturity into the next bucket.
*/
const MS_PER_DAY = 86_400_000;
/** Where the first bucket starts unless told otherwise: the day after the as-on date. */
const DAY_ONE: AlmSpan = { n: 1, unit: 'day' };
const MONTHS = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
/** One bucket laid over a calendar, for a given as-on date. */
export interface BucketWindow {
label: string;
/** First day the bucket takes. */
from: Date;
/** Last day the bucket takes, or null when it runs on without end. */
to: Date | null;
/** Days from the as-on date to the first / last day. */
fromDays: number;
toDays: number | null;
}
/** 'yyyy-MM-dd' to a local calendar date; null when it is not a real date. */
export function parseIsoDate(iso: string | null | undefined): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso ?? '');
if (!m) {
return null;
}
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
return date.getFullYear() === Number(m[1]) && date.getMonth() === Number(m[2]) - 1 && date.getDate() === Number(m[3])
? date
: null;
}
/** '30-Jun-2026'. */
export function formatDate(date: Date): string {
return `${String(date.getDate()).padStart(2, '0')}-${MONTHS[date.getMonth()]}-${date.getFullYear()}`;
}
/** Whole days from one calendar date to another; negative when `to` is earlier. */
export function daysBetween(from: Date, to: Date): number {
const a = Date.UTC(from.getFullYear(), from.getMonth(), from.getDate());
const b = Date.UTC(to.getFullYear(), to.getMonth(), to.getDate());
return Math.round((b - a) / MS_PER_DAY);
}
/**
* The date a span reaches. Months and years are calendar months and years, and a day that does not
* exist in the target month falls back to that month's last day (31 Jan + 1 month = 28/29 Feb).
*/
export function addSpan(from: Date, span: AlmSpan): Date {
if (span.unit === 'day') {
return new Date(from.getFullYear(), from.getMonth(), from.getDate() + span.n);
}
const months = span.unit === 'year' ? span.n * 12 : span.n;
const target = new Date(from.getFullYear(), from.getMonth() + months, 1);
const lastDay = new Date(target.getFullYear(), target.getMonth() + 1, 0).getDate();
return new Date(target.getFullYear(), target.getMonth(), Math.min(from.getDate(), lastDay));
}
/**
* Index of the bucket a maturity falls in. A maturity on or before the as-on date is already due, so
* it is slotted in the first bucket rather than falling out of the statement.
*/
export function bucketIndexFor(asOn: Date, maturity: Date, buckets: AlmBucket[]): number {
if (!buckets.length || maturity <= asOn) {
return 0;
}
for (let i = 0; i < buckets.length; i++) {
const upTo = buckets[i].upTo;
if (!upTo || maturity <= addSpan(asOn, upTo)) {
return i;
}
}
return buckets.length - 1;
}
/** Each bucket as a run of calendar days from the as-on date, in bucket order. */
export function bucketWindows(asOn: Date, buckets: AlmBucket[]): BucketWindow[] {
let from = addSpan(asOn, fromSpanOf(buckets, 0) ?? DAY_ONE);
return buckets.map((b) => {
const to = b.upTo ? addSpan(asOn, b.upTo) : null;
const window: BucketWindow = {
label: b.label,
from,
to,
fromDays: daysBetween(asOn, from),
toDays: to ? daysBetween(asOn, to) : null
};
if (to) {
from = new Date(to.getFullYear(), to.getMonth(), to.getDate() + 1);
}
return window;
});
}
/** 'Days 1 to 14', 'Days 29 to 92', 'Day 1827 onwards'. */
export function dayRangeText(w: BucketWindow): string {
return w.toDays === null ? `Day ${w.fromDays} onwards` : `Days ${w.fromDays} to ${w.toDays}`;
}
// ── The bucket ranges as a From / To table ──
/** '14 d', '3 m', '1 y' — how a span is written in the From / To table. */
export function spanText(span: AlmSpan): string {
return `${span.n} ${span.unit === 'day' ? 'd' : span.unit === 'month' ? 'm' : 'y'}`;
}
/** '14 days', '1 month', '5 years' — a span in words, as the statement's headings put it. */
function spanWords(span: AlmSpan): string {
const word = span.unit === 'day' ? 'day' : span.unit === 'month' ? 'month' : 'year';
return `${span.n} ${word}${span.n === 1 ? '' : 's'}`;
}
/**
* Where bucket `index` starts, as it is written in the table: the first starts on day 1, a bucket
* after one that ends in days starts the day after (14 d then 15 d), and one after a month or year
* starts "over" it (3 m then 3 m). Null for the first bucket, whose start is fixed.
*/
export function fromSpanOf(buckets: AlmBucket[], index: number): AlmSpan | null {
if (index === 0) {
return { ...(buckets[0]?.startSpan ?? DAY_ONE) };
}
const previous = buckets[index - 1].upTo;
if (!previous) {
return null;
}
return previous.unit === 'day' ? { n: previous.n + 1, unit: 'day' } : { ...previous };
}
/** Move the start of bucket `index`, which is the end of the one before it. */
export function setFromSpan(buckets: AlmBucket[], index: number, from: AlmSpan): void {
const n = Number(from.n) || 1;
if (index === 0) {
// Day 1 is the ordinary start, so it is stored as no override at all.
buckets[0].startSpan = n === 1 && from.unit === 'day' ? undefined : { n, unit: from.unit };
return;
}
buckets[index - 1].upTo = from.unit === 'day' ? { n: Math.max(1, n - 1), unit: 'day' } : { n, unit: from.unit };
}
/**
* The column heading a range deserves, worded the way the filed statement words them:
* '1 to 14 days', '29 days and upto 3 months', 'Over 3 months and upto 6 months', 'Over 5 years'.
*/
export function bucketLabel(buckets: AlmBucket[], index: number): string {
const to = buckets[index]?.upTo ?? null;
const previous = index > 0 ? buckets[index - 1].upTo : null;
if (index === 0) {
const start = buckets[0].startSpan;
if (start) {
if (start.unit === 'day') {
return !to
? `Over ${spanWords(start)}`
: to.unit === 'day'
? `${start.n} to ${to.n} days`
: `${start.n} days and upto ${spanWords(to)}`;
}
return to ? `Over ${spanWords(start)} and upto ${spanWords(to)}` : `Over ${spanWords(start)}`;
}
if (to?.unit === 'day' && to.n === 1) {
return 'Next day';
}
return !to ? 'Over 0 days' : to.unit === 'day' ? `1 to ${to.n} days` : `1 day and upto ${spanWords(to)}`;
}
if (!previous) {
return buckets[index].label;
}
if (previous.unit === 'day') {
const from = previous.n + 1;
if (!to) {
return `Over ${spanWords(previous)}`;
}
if (to.unit === 'day' && to.n === from) {
return `Day ${from}`;
}
return to.unit === 'day' ? `${from} to ${to.n} days` : `${from} days and upto ${spanWords(to)}`;
}
return to ? `Over ${spanWords(previous)} and upto ${spanWords(to)}` : `Over ${spanWords(previous)}`;
}
@@ -0,0 +1,125 @@
/**
* 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 { Injectable, inject } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { Observable } from 'rxjs';
/** One product's total, split by bucket, in rupees. */
export interface MaturityGroup {
key: string;
label: string;
side: 'liability' | 'asset';
accounts: number;
total: number;
buckets: number[];
}
/** Loans reported apart from the date buckets, with the reason they are not slotted by date. */
export interface NotSlotted {
key: string;
label: string;
accounts: number;
outstanding: number;
why: string;
}
export interface MaturityBreakdown {
asOnDate: string;
bucketCount: number;
groups: MaturityGroup[];
notSlotted: NotSlotted[];
notes: string[];
}
/** What the screen asks the server for: the date, the bank's own bucket edges and its overdue rule. */
export interface MaturityQuery {
asOnDate: string;
/** Edges like '14d,28d,3m,6m,1y,3y,5y'. */
buckets: string;
overdueRecentBucket: number;
overdueOldBucket: number;
}
/** One account found by a search, to be picked. */
export interface AccountHit {
/** FD, RD or LOAN. */
kind: 'FD' | 'RD' | 'LOAN';
id: number;
accountNo: string;
customer: string | null;
product: string;
/** Maturity date of a deposit; null for a loan. */
date: string | null;
/** Deposit balance, or loan principal outstanding. */
amount: number;
}
/** One deposit, or one unpaid loan instalment, of the accounts picked. */
export interface AccountRow {
id: number;
accountNo: string;
customer: string | null;
product: string;
kind: string;
/** Loan instalment number, for term loans. */
installment?: number;
/** Maturity date of a deposit, or due date of an instalment. */
date: string | null;
/** Days from the as-on date to that date; negative once it has passed. */
days: number | null;
status: string;
/** Position of the bucket it falls in. */
bucket: number | null;
amount: number;
/** Why the row needs a second look, for an NPA or a cash-credit / overdraft loan. */
note?: string | null;
}
export interface SelectedReport {
asOnDate: string;
bucketCount: number;
count: number;
total: number;
rows: AccountRow[];
}
/** Fixed-term deposits and loans of the bank's customers, slotted into residual-maturity buckets. */
@Injectable({ providedIn: 'root' })
export class AlmMaturityService {
private http = inject(HttpClient);
getBreakdown(query: MaturityQuery): Observable<MaturityBreakdown> {
const params = new HttpParams()
.set('asOnDate', query.asOnDate)
.set('buckets', query.buckets)
.set('overdueRecentBucket', query.overdueRecentBucket)
.set('overdueOldBucket', query.overdueOldBucket);
return this.http.get<MaturityBreakdown>('/almreturn/maturity-breakdown', { params });
}
/** Find accounts by account number or customer name; at least two characters. A kind of FD, RD or LOAN narrows it. */
searchAccounts(q: string, kind: string): Observable<{ results: AccountHit[] }> {
let params = new HttpParams().set('q', q);
if (kind) {
params = params.set('kind', kind);
}
return this.http.get<{ results: AccountHit[] }>('/almreturn/maturity-breakdown/search', { params });
}
/** The day-wise breakdown of the picked accounts, given as KIND:id pairs like FD:12,RD:5,LOAN:33. */
getSelected(accounts: string, query: MaturityQuery): Observable<SelectedReport> {
const params = new HttpParams()
.set('accounts', accounts)
.set('asOnDate', query.asOnDate)
.set('buckets', query.buckets)
.set('overdueRecentBucket', query.overdueRecentBucket)
.set('overdueOldBucket', query.overdueOldBucket);
return this.http.get<SelectedReport>('/almreturn/maturity-breakdown/selected', { params });
}
}
@@ -15,7 +15,7 @@
RBI Liabilities Register
</div>
<h2 class="dialog-title">Demand &amp; Time Liabilities</h2>
<p class="dialog-sub">NDTL &middot; CRR/SLR Computation Base &middot; As per RBI Master Circular</p>
<p class="dialog-sub">What the bank owes, and what it must hold against it</p>
</div>
<div class="header-right">
<!-- The close being reported, and under it the close the ratios are charged on. Two dates,
@@ -45,36 +45,39 @@
</div>
</div>
<!-- Summary Cards -->
<!-- What the liabilities are, and what they come to. Four cards rather than five: the CRR
requirement that used to sit here is on the compliance bar below, beside what is held
against it, where it answers a question rather than repeating one. -->
<div class="summary-row">
<div class="summary-card sc-demand">
<div class="sc-label">Total Demand Liabilities</div>
<div class="sc-value">{{ crore(totalDemand) }}<span class="cr" *ngIf="totalDemand !== null">Cr</span></div>
<div class="sc-sub">Payable on demand</div>
<div class="sc-label">Demand liabilities</div>
<div class="sc-value" [title]="money(totalDemand)">{{ tile(totalDemand) }}</div>
<div class="sc-sub">Repayable on demand</div>
</div>
<div class="summary-card sc-time">
<div class="sc-label">Total Time Liabilities</div>
<div class="sc-value">{{ crore(totalTime) }}<span class="cr" *ngIf="totalTime !== null">Cr</span></div>
<div class="sc-sub">Payable on maturity</div>
<div class="sc-label">Time liabilities</div>
<div class="sc-value" [title]="money(totalTime)">{{ tile(totalTime) }}</div>
<div class="sc-sub">Repayable on maturity</div>
</div>
<div class="summary-card sc-asset">
<div class="sc-label">Assets with Banking System</div>
<div class="sc-value">
{{ crore(totalBankingAssets) }}<span class="cr" *ngIf="totalBankingAssets !== null">Cr</span>
<div class="sc-label">Held with other banks</div>
<!-- Scaled on the tile, exact on hover: the card is read for order of magnitude, but the
figure behind it should never be more than a pointer away. -->
<div class="sc-value" [title]="money(totalBankingAssets)">
{{ tile(totalBankingAssets) }}
</div>
<!-- These assets net against what the bank OWES other banks, not against the whole base, and
the netting floors at zero — so where they exceed what is owed, nothing comes off at all.
Said plainly, because "deducted from NDTL" was being printed on a card where no
deduction had been made. -->
<div class="sc-sub" [class.dn]="bankingAssetsNet">
{{ bankingAssetsNet ? 'Reduces the base below' : 'More than we owe banks — base unchanged' }}
</div>
<div class="sc-change dn">&#8595; Deducted from NDTL</div>
</div>
<div class="summary-card sc-ndtl">
<div class="sc-label">Net Demand &amp; Time Liabilities (NDTL)</div>
<div class="sc-value">{{ crore(ndtl) }}<span class="cr" *ngIf="ndtl !== null">Cr</span></div>
<div class="sc-sub">After deducting banking assets</div>
</div>
<div class="summary-card sc-crr">
<!-- The rate is named from the configuration, not fixed here: a card headed "CRR Required
(3%)" while Admin holds 4% states a rate the bank is not on. -->
<div class="sc-label">CRR Required{{ crrRate === null ? '' : ' (' + crrRate + '%)' }}</div>
<div class="sc-value">{{ crore(crrRequired) }}<span class="cr" *ngIf="crrRequired !== null">Cr</span></div>
<div class="sc-sub">SLR Req: {{ crore(slrRequired) }}{{ slrRate === null ? '' : ' Cr (' + slrRate + '%)' }}</div>
<div class="sc-label">NDTL &mdash; the base</div>
<div class="sc-value" [title]="money(ndtl)">{{ tile(ndtl) }}</div>
<div class="sc-sub">Demand + time, net of bank balances</div>
</div>
</div>
@@ -94,26 +97,33 @@
<span class="tab-pill pill-time">02</span> Time Liabilities
</button>
<button class="tab-btn" [class.active]="activeTab === 'assets'" (click)="activeTab = 'assets'">
<span class="tab-pill pill-asset">03</span> Assets with Banking System
<span class="tab-pill pill-asset">03</span> Held with other banks
</button>
<button class="tab-btn" [class.active]="activeTab === 'slr'" (click)="activeTab = 'slr'">
<span class="tab-pill pill-slr">04</span> SLR &mdash; Liquid Assets
<span class="tab-pill pill-slr">04</span> SLR assets
</button>
<!-- Where the reserve is actually held, and what it holds TODAY. The four tabs above report the
fortnight close the return is made up to; this one answers the question a treasury desk
asks every morning — as things stand now, are we short, and which account do we cover it
into. A close two weeks old cannot say that, because the balances have since moved. -->
<button class="tab-btn" [class.active]="activeTab === 'held'" (click)="activeTab = 'held'">
<span class="tab-pill pill-held">05</span> Our accounts today
</button>
</div>
<!-- Demand Liabilities Table -->
@if (activeTab === 'demand') {
<div class="section-desc">Amounts payable on demand &mdash; RBI Schedule II</div>
<div class="section-desc">Money the bank must repay whenever a customer asks for it.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liability Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -167,17 +177,17 @@
<!-- Time Liabilities Table -->
@if (activeTab === 'time') {
<div class="section-desc">Amounts payable after a specified period &mdash; RBI Schedule III</div>
<div class="section-desc">Money the bank repays only when the deposit matures.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liability Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -231,17 +241,20 @@
<!-- Assets with Banking System Table -->
@if (activeTab === 'assets') {
<div class="section-desc">Deducted from gross liabilities to arrive at NDTL &mdash; RBI Master Circular</div>
<div class="section-desc">
What the bank keeps with other banks. This comes off what it owes them — and only off that, so where it is the
larger of the two the base is left as it is.
</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Asset Item</th>
<th>Account Code</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th>Item</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -275,7 +288,12 @@
<tfoot>
<tr>
<td colspan="3">
<strong>Total Assets with Banking System</strong> <span class="deduct-tag">( Deducted from NDTL )</span>
<strong>Total Assets with Banking System</strong>
<span class="deduct-tag">{{
bankingAssetsNet
? '( Deducted from NDTL )'
: '( Netted against item I, which is smaller — no deduction )'
}}</span>
</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'openingValue') }}</td>
<td class="right foot-asset">{{ totalOf(bankingAssets, 'currentValue') }}</td>
@@ -289,20 +307,18 @@
<!-- SLR - Liquid Assets Table -->
@if (activeTab === 'slr') {
<div class="section-desc">
SLR = (Liquid Assets / NDTL) &times; 100 &mdash; RBI Sec 24(2A) of Banking Regulation Act, 1949
</div>
<div class="section-desc">Cash, bank balances and securities the bank holds against its SLR requirement.</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Liquid Asset</th>
<th>Account Code</th>
<th>Item</th>
<th>Category</th>
<th class="right">Opening (&#8377; Cr)</th>
<th class="right">Current (&#8377; Cr)</th>
<th class="right">Change (&#8377; Cr)</th>
<th class="right">Opening</th>
<th class="right">Current</th>
<th class="right">Change</th>
<th>Status</th>
</tr>
</thead>
@@ -348,119 +364,141 @@
</table>
</div>
<!-- SLR Formula -->
<!-- How the ratio is struck. The "× 100" box that stood here was a term whose value was 100 and
whose label was 100 — it took a quarter of the row to say that a fraction is written as a
percentage. -->
<div class="formula-block slr-formula">
<div class="formula-title">SLR Computation &mdash; (Liquid Assets / NDTL) &times; 100</div>
<div class="formula-title">How the SLR ratio is worked out</div>
<div class="formula-row">
<div class="formula-term ft-slr">
<div class="formula-term-label">Liquid Assets</div>
<div class="formula-term-val">&#8377;13,238.80</div>
<div class="formula-term-sub">6 asset categories</div>
<div class="formula-term-label">Assets we hold</div>
<div class="formula-term-val">{{ money(totalLiquidAssets) }}</div>
<div class="formula-term-sub">{{ liquidAssets.length }} categories</div>
</div>
<div class="formula-op">&divide;</div>
<div class="formula-term ft-ndtl">
<div class="formula-term-label">NDTL</div>
<div class="formula-term-val">&#8377;69,221.30</div>
<div class="formula-term-sub">Net Demand &amp; Time</div>
</div>
<div class="formula-op">&times;</div>
<div class="formula-term" style="border-color: #e2e8f0; background: #f8fafc">
<div class="formula-term-label" style="color: #64748b">100</div>
<div class="formula-term-val" style="color: #334155">100</div>
<div class="formula-term-sub">&nbsp;</div>
<div class="formula-term-label">NDTL base</div>
<div class="formula-term-val">{{ money(baseNdtl) }}</div>
<div class="formula-term-sub">as at {{ baseDateStr || '—' }}</div>
</div>
<div class="formula-op equals">=</div>
<div class="formula-term ft-slr-result">
<div class="formula-term-label">Actual SLR</div>
<div class="formula-term-label">SLR held</div>
<div class="formula-term-val">{{ slrActualPercent }}%</div>
<div class="formula-term-sub">Min required: {{ slrRate }}%</div>
<div class="formula-term-sub">minimum {{ slrRate }}%</div>
</div>
</div>
</div>
<!-- SLR Status Bar -->
<div class="slr-status-bar">
<div class="slr-status-left">
<div class="slr-status-title">SLR Compliance Status</div>
<div class="slr-status-desc">
Actual: {{ slrActualPercent }}% &middot; Required: {{ slrRate }}.00% &middot; NDTL: &#8377;{{
ndtl | number: '1.2-2'
}}
Cr
</div>
</div>
<div class="slr-status-calcs">
<div class="slr-calc">
<div class="slr-calc-label">SLR Required ({{ slrRate }}%)</div>
<div class="slr-calc-val">&#8377;{{ slrRequired | number: '1.2-2' }} Cr</div>
</div>
<div class="slr-calc">
<div class="slr-calc-label">Liquid Assets Held</div>
<div class="slr-calc-val">&#8377;{{ totalLiquidAssets | number: '1.2-2' }} Cr</div>
</div>
<div class="slr-calc">
<div class="slr-calc-label">Surplus</div>
<div class="slr-calc-val surplus">&#8377;{{ slrSurplus | number: '1.2-2' }} Cr</div>
</div>
</div>
</div>
<!-- The SLR compliance bar that used to sit here said the same three figures as the row above
it and the bar below it — required, held, surplus, three times on one screen. It is gone;
the compliance bar at the foot carries both ratios for every tab. -->
}
<!-- NDTL Formula Block -->
<div class="formula-block">
<div class="formula-title">
NDTL Computation &mdash; Formula Breakdown &middot; Data as of {{ reportingDateStr }}
<!-- Required against held, for both ratios. Always on screen, under whichever tab is open:
whether the bank is short is the one thing a reader must not have to go looking for. -->
<div class="crr-bar">
<div class="crr-text">
<div class="crr-title">Are we meeting the requirement?</div>
<div class="crr-desc">
Charged on the NDTL of {{ money(baseNdtl) }} as at {{ baseDateStr || reportingDateStr }}
</div>
</div>
<div class="formula-row">
<div class="formula-term ft-demand">
<div class="formula-term-label">Demand Liabilities</div>
<div class="formula-term-val">&#8377;{{ totalDemand | number: '1.2-2' }}</div>
<div class="formula-term-sub">16 line items</div>
</div>
<div class="formula-op">+</div>
<div class="formula-term ft-time">
<div class="formula-term-label">Time Liabilities</div>
<div class="formula-term-val">&#8377;{{ totalTime | number: '1.2-2' }}</div>
<div class="formula-term-sub">13 line items</div>
</div>
<div class="formula-op minus">&minus;</div>
<div class="formula-term ft-asset">
<div class="formula-term-label">Assets with Banking</div>
<div class="formula-term-val">&#8377;{{ totalBankingAssets | number: '1.2-2' }}</div>
<div class="formula-term-sub">3 line items</div>
</div>
<div class="formula-op equals">=</div>
<div class="formula-term ft-ndtl">
<div class="formula-term-label">NDTL</div>
<div class="formula-term-val">&#8377;{{ ndtl | number: '1.2-2' }}</div>
<div class="formula-term-sub">CRR &amp; SLR base</div>
<!-- Required AND held, side by side. The requirement alone answers half the question: a screen
headed "Reserve Requirements" that states ₹2.10 Cr without saying what is against it sends
a reader to another tab to find out whether the bank is short — which is the one thing they
opened this bar to learn. -->
<div class="crr-calcs">
@for (r of positionRatios(); track r.name) {
<div class="crr-calc" [class.crr-calc--short]="!r.met">
<!-- A ratio with no rate on record is said to have none. It used to print "SLR @ null%" and
a surplus of nothing, which reads as a requirement met rather than one not set. -->
<div class="crr-calc-label">{{ r.name }}{{ r.rate === null ? ' — rate not set' : ' @ ' + r.rate + '%' }}</div>
<div class="crr-calc-val">{{ money(r.required) }}</div>
<div class="crr-calc-held">
held {{ money(r.maintained) }}
<span [class.crr-short]="!r.met">
&middot; {{ r.met ? '+' + money(r.surplus) : 'short ' + money(r.shortfall) }}
</span>
</div>
</div>
}
<div class="crr-calc">
<div class="crr-calc-label">Total Reserve</div>
<div class="crr-calc-val">{{ money(totalReserve) }}</div>
<div class="crr-calc-held">CRR + SLR required</div>
</div>
</div>
</div>
<!-- CRR/SLR Bar -->
<div class="crr-bar">
<div class="crr-text">
<div class="crr-title">CRR &amp; SLR Reserve Requirements &mdash; RBI Computation on NDTL</div>
<div class="crr-desc">
Based on NDTL of &#8377;{{ ndtl | number: '1.2-2' }} Cr &middot; Data as of {{ reportingDateStr }} (45-day lag)
</div>
<!-- Reserve accounts, as they stand today -->
@if (activeTab === 'held') {
<div class="section-desc">
Where the cash reserve is held, with each account's balance on the business date
<strong>{{ position?.onDate || '—' }}</strong
>. The requirement below is charged on <strong>{{ position?.baseDate || '—' }}</strong
>, a fortnight back — RBI's lag is a rule, so today's own liabilities settle nothing yet.
</div>
<div class="crr-calcs">
<div class="crr-calc">
<div class="crr-calc-label">CRR @ {{ crrRate }}.00%</div>
<div class="crr-calc-val">&#8377;{{ crrRequired | number: '1.2-2' }} Cr</div>
@if (position) {
<!-- Required against held, for each ratio. Stated before the accounts because a desk reads
the gap first and only then asks which account to move money into. -->
<div class="held-ratios">
@for (r of positionRatios(); track r.name) {
<div class="held-ratio" [class.held-ratio--short]="!r.met">
<div class="held-ratio__name">{{ r.name }}{{ r.rate === null ? '' : ' @ ' + r.rate + '%' }}</div>
<div class="held-ratio__grid">
<span>Required</span><strong>{{ money(r.required) }}</strong> <span>Maintained</span
><strong>{{ money(r.maintained) }}</strong>
<span>{{ r.met ? 'Surplus' : 'Shortfall' }}</span>
<strong [class.held-short]="!r.met">{{ money(r.met ? r.surplus : r.shortfall) }}</strong>
</div>
</div>
}
</div>
<div class="crr-calc">
<div class="crr-calc-label">SLR @ {{ slrRate }}.00%</div>
<div class="crr-calc-val">&#8377;{{ slrRequired | number: '1.2-2' }} Cr</div>
<div class="table-wrap">
<table>
<thead>
<tr>
<th class="sl">#</th>
<th>Account</th>
<th>Form 1 head</th>
<th class="right">Balance today</th>
<th>&nbsp;</th>
</tr>
</thead>
<tbody>
@for (a of position.accounts; track a.glAccountId) {
<tr>
<td class="sl">{{ $index + 1 }}</td>
<td>
<div class="item-name">{{ a.name }}</div>
<div class="item-sub mono">{{ a.glCode }}</div>
</td>
<td>
{{ a.item }} &middot; {{ a.head }}
<!-- An account the return does not read is named as such. It can still be moved
into — the money is real — but adding it to what is maintained would have
this screen claim a compliance the filed form does not. -->
@if (!a.countsTowardMaintained) {
<span class="held-chip">not counted on the return</span>
}
</td>
<td class="right amt">{{ money(a.balance) }}</td>
<td class="right">
<button class="held-move" (click)="openTransfer(a)">
<fa-icon icon="exchange-alt"></fa-icon>
Transfer
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="crr-calc">
<div class="crr-calc-label">Total Reserve</div>
<div class="crr-calc-val">&#8377;{{ totalReserve | number: '1.2-2' }} Cr</div>
</div>
</div>
</div>
}
}
<!-- Footer -->
<div class="dialog-footer">
@@ -158,7 +158,10 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
// ===== Summary Cards =====
.summary-row {
display: grid;
grid-template-columns: repeat(5, 1fr);
// Five cards only while each can still hold its figure. A seventy-crore NDTL printed to the
// paise needs room, and fixed fifths cut it off at the card edge — the figures were there and
// unreadable. Below that width the row wraps to fewer, wider cards instead.
grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
gap: 14px;
padding: 20px 28px;
}
@@ -167,8 +170,12 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
background: $surface;
border: 1px solid $border;
border-radius: 12px;
padding: 16px 18px;
// 18px of side padding on a fifth of the row left the figure barely more than half the card.
padding: 16px 14px;
position: relative;
// A grid item will not shrink below its content by default, which is how a long figure pushed
// its own card wider than its column and off the edge of the dialog.
min-width: 0;
overflow: hidden;
transition: box-shadow 0.2s;
@@ -210,16 +217,29 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
font-size: 10px;
color: $ink3;
font-family: $mono;
letter-spacing: 1px;
// Tightened from 1px: on a five-up row the tracking alone pushed "NET DEMAND & TIME LIABILITIES
// (NDTL)" onto a third line and squeezed the figure beneath it.
letter-spacing: 0.4px;
text-transform: uppercase;
margin-bottom: 6px;
// Wraps rather than clipping — a heading that reads "ASSETS WITH BANKIN" names nothing.
overflow-wrap: anywhere;
}
.sc-value {
font-size: 20px;
// Sized to the card rather than fixed: a figure that outgrows its box is worse than a smaller
// one, because the digits that fall off the right are the ones that distinguish it. Floors at
// 14px so a long figure shrinks rather than wraps mid-number.
font-size: clamp(14px, 1.45vw, 20px);
font-weight: 600;
color: $ink;
font-family: $mono;
// Figures are read by lining their digits up; a proportional fallback would stagger them.
font-variant-numeric: tabular-nums;
// A rupee figure never breaks across lines — "₹6,35,05," over "967.50" reads as two numbers.
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cr {
@@ -232,12 +252,16 @@ $mono: 'IBM Plex Mono', 'Roboto Mono', monospace;
.sc-change {
font-size: 11px;
margin-top: 4px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.sc-sub {
font-size: 11px;
color: $ink3;
margin-top: 4px;
line-height: 1.35;
overflow-wrap: anywhere;
}
.up {
@@ -869,3 +893,112 @@ $slr-b: #bae6fd;
line-height: 1.6;
background: #fafafa;
}
/* ── Reserve accounts, as they stand today ── */
.pill-held {
background: #0b5394;
color: #fff;
}
/* Required against held, per ratio — read before the accounts, because a desk asks how big the
gap is before asking which account to cover it from. */
.held-ratios {
display: flex;
gap: 16px;
padding: 16px 20px 0;
flex-wrap: wrap;
}
.held-ratio {
flex: 1 1 260px;
padding: 14px 16px;
border: 1px solid #e2e8f0;
border-left: 4px solid #137333;
border-radius: 8px;
background: #f8faf9;
}
/* A ratio that is not met is the thing this tab exists to surface. */
.held-ratio--short {
border-left-color: #c5221f;
background: #fdf6f6;
}
.held-ratio__name {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: #5f6368;
margin-bottom: 10px;
}
.held-ratio__grid {
display: grid;
grid-template-columns: auto 1fr;
gap: 4px 14px;
font-size: 13px;
span {
color: #5f6368;
}
strong {
text-align: right;
font-variant-numeric: tabular-nums;
}
}
.held-short {
color: #c5221f;
}
/* An account the filed return does not read. Named rather than hidden: the money is real and can
be moved, but it settles nothing on the form. */
.held-chip {
display: inline-block;
margin-left: 8px;
padding: 1px 7px;
border-radius: 10px;
background: #fff4e5;
color: #b06000;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
}
.held-move {
padding: 5px 12px;
border: 1px solid #0b5394;
border-radius: 6px;
background: #fff;
color: #0b5394;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
&:hover {
background: #0b5394;
color: #fff;
}
}
/* What is held against each requirement, under the requirement itself. */
.crr-calc-held {
margin-top: 4px;
font-size: 11px;
color: $ink3;
white-space: nowrap;
}
/* A ratio that is not met — on a compliance bar this is the one thing that must not be missed. */
.crr-calc--short {
border-color: $red;
}
.crr-short,
.sc-short {
color: $red;
font-weight: 600;
}
@@ -8,12 +8,14 @@
import { Component, OnInit, inject } from '@angular/core';
import { CommonModule, DecimalPipe } from '@angular/common';
import { MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatDialog, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { MatTabsModule } from '@angular/material/tabs';
import { MatTooltipModule } from '@angular/material/tooltip';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { ReserveTransferDialogComponent } from './reserve-transfer-dialog.component';
/** One account under a head, at the base date and the reporting date. */
interface CrrSlrRow {
@@ -31,6 +33,47 @@ interface CrrSlrRow {
status: string;
}
/** One reserve account, with what it holds on the business date. */
interface ReserveAccount {
glAccountId: number;
glCode: string;
name: string;
balance: number;
/** The Form 1 item the account answers — V, VI(a), VI(b), VI(c). */
item: string;
head: string;
/**
* Whether the filed return reads this balance.
*
* An account the bank holds but has not mapped is still somewhere money can be moved, so it is
* listed — but counting it toward what is maintained would have this screen claim a compliance
* the return does not.
*/
countsTowardMaintained: boolean;
}
/** One ratio's standing: what it needs, what it has, and which way the gap runs. */
interface ReserveRatio {
required: number;
maintained: number;
surplus: number;
/** Positive — a shortfall is an amount to cover, not a negative surplus. */
shortfall: number;
met: boolean;
}
/** The reserve position as it stands on the business date. */
interface ReservePosition {
onDate: string;
/** The close the requirement is charged on — a fortnight back, by RBI's lag rule. */
baseDate: string;
crrPercentage: number | null;
slrPercentage: number | null;
crr: ReserveRatio;
slr: ReserveRatio;
accounts: ReserveAccount[];
}
@Component({
selector: 'mifosx-crr-slr-dialog',
standalone: true,
@@ -49,8 +92,18 @@ interface CrrSlrRow {
export class CrrSlrDialogComponent implements OnInit {
private dialogRef = inject(MatDialogRef<CrrSlrDialogComponent>);
private rbi = inject(RbiCircularsService);
private dialog = inject(MatDialog);
private auth = inject(AuthenticationService);
activeTab: 'demand' | 'time' | 'assets' | 'slr' = 'demand';
activeTab: 'demand' | 'time' | 'assets' | 'slr' | 'held' = 'demand';
/**
* The reserve position on the business date — null until it loads, or if it cannot be read.
*
* Separate from the return the other tabs show: those report a fortnight close, this reports
* where the money is now.
*/
position: ReservePosition | null = null;
/** The date the figures were struck at, as the return itself reports it. */
reportingDateStr = '';
@@ -82,7 +135,23 @@ export class CrrSlrDialogComponent implements OnInit {
totalDemand: number | null = null;
totalTime: number | null = null;
totalBankingAssets: number | null = null;
/**
* Whether item III was actually deducted from the base.
*
* It nets against item I rather than against the whole base, and the netting floors at zero — so
* a bank holding more with other banks than it owes them has nothing deducted at all.
*/
bankingAssetsNet = false;
/** Item IV(a) — the bank's own net liabilities on the date being reported. */
ndtl: number | null = null;
/**
* Item IV(b) — the base a fortnight back, which both ratios are actually charged on.
*
* A separate figure from `ndtl` above and not interchangeable with it: the requirement is struck
* on this one, so a ratio or a tile that quotes the other states a percentage of something the
* requirement was never computed from.
*/
baseNdtl: number | null = null;
crrRate: number | null = null;
slrRate: number | null = null;
crrRequired: number | null = null;
@@ -98,7 +167,7 @@ export class CrrSlrDialogComponent implements OnInit {
* A dash and a zero say different things — "not reported" against "reported as nil" — and on a
* regulatory screen the difference decides whether anyone goes looking for the missing mapping.
*/
crore(value: number | null): string {
rupees(value: number | null): string {
if (value === null) {
return '—';
}
@@ -106,9 +175,19 @@ export class CrrSlrDialogComponent implements OnInit {
}
ngOnInit(): void {
// The return is reported in rupees; this screen reads in crore, as a dashboard tile does.
const toCrore = (v: number | null | undefined): number | null =>
v === null || v === undefined ? null : +(v / 1e7).toFixed(2);
// Today's position, alongside the return below. The two answer different questions and are
// fetched separately: the return reports a fortnight close, this reports the balances as they
// stand on the business date, which is what a desk can still act on.
this.rbi.getReservePosition().subscribe({
next: (data: ReservePosition) => (this.position = data),
// A position that cannot be read leaves the other four tabs working. The tab simply shows
// nothing rather than taking the whole dialog down with it.
error: () => (this.position = null)
});
// Figures stay in rupees. They were rounded to crore until now, which on a bank this size threw
// away everything that tells one fortnight from the next — a seventy-crore NDTL and the one a
// fortnight later both read "₹70 Cr".
// The statutory Form 1, not the working report. Form 1 is filed twice a month — at the 15th and
// at the month end — and this screen should show the close the bank last filed, not a position
@@ -139,27 +218,66 @@ export class CrrSlrDialogComponent implements OnInit {
// Part A's own lines, read by the Roman numeral each carries. Read from the return rather
// than recomputed, so this screen and the filed form cannot come to disagree.
const lines: any[] = data?.partA?.lines ?? [];
// Matched without regard to case: the form prints its heads in capitals — "(A) DEMAND
// LIABILITIES :" — and matching them as written left every tile reading a dash while the
// figures sat in the response.
// Matched without regard to case, and only against lines that actually carry figures. The
// form heads item I with "(a) Demand Liabilities:" — a caption with no values — directly
// above item II's "(A) DEMAND LIABILITIES :", which holds the figure. Matching on text
// alone found the caption first and read a dash off it.
const at = (startsWith: string): number | null => {
const line = lines.find((l: any) => String(l?.label ?? '').startsWith(startsWith));
const want = startsWith.toUpperCase();
const line = lines.find(
(l: any) =>
Array.isArray(l?.values) &&
String(l?.label ?? '')
.toUpperCase()
.startsWith(want)
);
return line?.values?.[latest] ?? null;
};
this.totalDemand = toCrore(at('(A) Demand liabilities'));
this.totalTime = toCrore(at('(B) Time liabilities'));
this.totalBankingAssets = toCrore(at('TOTAL OF III'));
// IV(b) is the base the ratios are actually charged on — the figure behind the requirement,
// not the bank's liabilities on the day being reported.
this.ndtl = toCrore(at('IV. b)'));
this.crrRequired = toCrore(data?.partB?.required?.[latest]);
this.slrRequired = toCrore(data?.partC?.required?.[latest]);
this.totalLiquidAssets = toCrore(data?.partC?.maintained?.[latest]);
// Item II's own two lines, matched including the trailing colon the form prints. Item I
// carries a "(b) Time Liabilities to the banking system" of its own — a different figure,
// and the one a looser match found first.
this.totalDemand = at('(A) DEMAND LIABILITIES :');
this.totalTime = at('(B) TIME LIABILITIES :');
this.totalBankingAssets = at('TOTAL OF III');
// Whether item III actually came off the base. The form nets it against item I — the
// liabilities OWED TO the banking system — and floors that netting at zero: where the
// bank's assets with other banks exceed what it owes them, the excess is NOT deducted and
// the base is item II alone. Stating "deducted from NDTL" in that case names a subtraction
// the return did not make, and a reader checking the arithmetic finds it does not work.
const liabilitiesToBanks = at('TOTAL OF I');
this.bankingAssetsNet =
liabilitiesToBanks !== null &&
this.totalBankingAssets !== null &&
liabilitiesToBanks > this.totalBankingAssets;
// Two different figures, and the screen needs both.
//
// IV(a) is the bank's OWN net liabilities on the reporting date — what the demand, time and
// banking-asset tiles add up to, so it is the one the formula row must end at. IV(b) is the
// base a fortnight back, which is what the requirement is charged on.
//
// Showing IV(b) as the result of the formula was arithmetic that did not work: three terms
// struck on one date reaching a figure struck on another, off by whatever the liabilities
// had moved in between.
this.ndtl = at('IV. a)');
this.baseNdtl = at('IV. b)');
this.crrRequired = data?.partB?.required?.[latest] ?? null;
this.slrRequired = data?.partC?.required?.[latest] ?? null;
this.totalLiquidAssets = data?.partC?.maintained?.[latest] ?? null;
if (this.slrRequired !== null && this.totalLiquidAssets !== null) {
this.slrSurplus = +(this.totalLiquidAssets - this.slrRequired).toFixed(2);
}
if (this.ndtl) {
// Struck on the base, not on today's own liabilities: the requirement below it is charged
// on the base, and a ratio measured against a different denominator from the one the
// requirement uses cannot be compared with it — "33.62% against a minimum of 18%" would be
// two percentages of two different things.
if (this.baseNdtl) {
this.slrActualPercent =
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.ndtl) * 100).toFixed(2);
this.totalLiquidAssets === null ? null : +((this.totalLiquidAssets / this.baseNdtl) * 100).toFixed(2);
}
if (this.crrRequired !== null && this.slrRequired !== null) {
this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2);
@@ -209,13 +327,20 @@ export class CrrSlrDialogComponent implements OnInit {
// The form's own wording, with its numbering trimmed off the front — the table has a column
// of its own for that.
name: label.replace(/^\s*\(?[a-zA-Z0-9]+\)?[.)]?\s*/, '').trim() || label.trim(),
sub: line.configKey ? String(line.configKey).replace(/_/g, ' ') : 'Derived from the form',
code: line.configKey ?? '—',
opening: this.crore(opening),
current: this.crore(current),
// The form's own numbering — I(a)(ii), II(A), VII(c) — rather than the configuration key
// behind it. A reader checking this screen against the filed return looks for the item
// number; FORM1_I_A_II_OTHER_DEMAND_BS is the name of a database row and means nothing to
// them.
sub: '',
code: (label.match(/^\s*\(?([IVXivx]+|[A-Za-z0-9]+)\)?[.)]?/) ?? [
,
'—'
])[1] ?? '—',
opening: this.rupees(opening),
current: this.rupees(current),
openingValue: opening,
currentValue: current,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`,
change: change === null ? '—' : `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`,
changeDir: change === null ? 'flat' : change >= 0 ? 'up' : 'dn',
// A head with no GL mapped reports nothing, and the form says so rather than reporting nil.
status: line.configured === false ? 'Not mapped' : 'Active'
@@ -235,7 +360,7 @@ export class CrrSlrDialogComponent implements OnInit {
*/
totalOf(rows: CrrSlrRow[], column: 'openingValue' | 'currentValue'): string {
const values = rows.map((r) => r[column]).filter((v): v is number => v !== null);
return values.length ? this.crore(values.reduce((a, b) => a + b, 0)) : '—';
return values.length ? this.rupees(values.reduce((a, b) => a + b, 0)) : '—';
}
/** The movement across a tab, between the two closes the form reports. */
@@ -246,7 +371,7 @@ export class CrrSlrDialogComponent implements OnInit {
return '—';
}
const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0);
return `${change >= 0 ? '+' : ''}${this.crore(change).replace('₹', '')}`;
return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`;
}
/** Fills the four tabs from one reading of the form. */
@@ -257,16 +382,148 @@ export class CrrSlrDialogComponent implements OnInit {
};
// Matched on how the form labels each head, not on a fixed list of account names: a bank that
// maps a new account to a head gets it on the right tab without this screen being changed.
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit'));
this.timeLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'time liabilit') && !has(l, 'demand and time'));
// III — what the banking system owes this bank, which nets off the liability base.
this.bankingAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'current account balances with sbi', 'balances in all accounts', 'money at call', 'advances and any other')
// Item II only — what the bank owes its own depositors. Item I's lines are worded the same way
// but are owed to the banking system, and the form nets them against item III rather than
// adding them here, so a tab headed "payable on demand" that carried both would double-count
// the one and misstate the other.
const toBankingSystem = (l: string): boolean => has(l, 'to the banking system', 'banking system');
this.demandLiabilities = this.rowsFrom(lines, latest, (l) => has(l, 'demand liabilit') && !toBankingSystem(l));
this.timeLiabilities = this.rowsFrom(
lines,
latest,
(l) => has(l, 'time liabilit') && !has(l, 'demand and time') && !toBankingSystem(l)
);
// V, VI and VII — the cash, balances and securities the two ratios are met in.
this.liquidAssets = this.rowsFrom(lines, latest, (l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co-operative bank', 'government securities')
// III — what the banking system owes this bank, which nets off the liability base. Matched on
// the words the form actually prints: its item III(a) reads "TOTAL OF CREDIT BALANCES IN
// CURRENT ACCOUNT maintained with S.B.I...", which the shorter phrases this looked for never
// appeared in, so the tab came out empty while its total showed a figure.
this.bankingAssets = this.rowsFrom(
lines,
latest,
(l) =>
has(
l,
'credit balances in current account',
'other assets with the banking system',
'balances in all accounts',
'money at call',
'advances & any other'
) &&
// Item I(a)(i) is worded almost identically but is the opposite side of the ledger: what
// the banking system holds WITH this bank, a liability. On a tab headed "deducted from
// gross liabilities" it would be read as an asset and netted the wrong way.
!has(l, 'maintained with the co-operative bank by state')
);
// V, VI and VII — the cash, balances and securities the two ratios are met in. Item I's own
// SBI line is excluded: it is a liability TO the banking system, and listing it among the
// assets held against a ratio reads as something the bank can count toward it.
this.liquidAssets = this.rowsFrom(
lines,
latest,
(l) =>
has(l, 'cash on hand', 'reserve bank of india', 'co.op.bank', 'co-operative bank', 'govt. security') &&
!has(l, 'maintained with the co-operative bank by state')
);
}
/**
* The two ratios as rows, so the template states them once rather than twice over.
*/
positionRatios(): {
name: string;
rate: number | null;
required: number;
maintained: number;
surplus: number;
shortfall: number;
met: boolean;
}[] {
if (!this.position) {
return [];
}
return [
{ name: 'CRR', rate: this.position.crrPercentage, ...this.position.crr },
{ name: 'SLR', rate: this.position.slrPercentage, ...this.position.slr }
];
}
/**
* A reserve figure in rupees, to the paisa.
*
* Not rounded like the table cells above: a desk covering a shortfall transfers an exact amount, and
* "₹0.37 Cr" is not an amount anyone can send.
*/
money(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* A figure for a summary tile, scaled so it fits however large the bank grows.
*
* Tiles are read for order of magnitude, so they carry whole rupees up to a crore and switch to
* crore above it: ₹70,10,45,062 is nineteen characters and overflows its card, where ₹70.10 Cr
* is seven and says the same thing to anyone glancing at it. The exact amount, to the paise, is
* on the tables below and on the reserve-accounts tab — which is where anyone acting on a figure
* rather than scanning it goes.
*
* Scaled rather than merely shrunk, because a fixed format only ever fits the balances the bank
* happens to hold today. This one holds at four or five characters whether the figure is lakhs
* or lakh-crore, so a bank that grows ten-fold does not come back to a row of clipped tiles.
*/
tile(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
const abs = Math.abs(value);
const sign = value < 0 ? '-' : '';
// Indian scale: a crore is 1e7, a lakh 1e5. Below a lakh the whole rupee figure is already
// short enough that scaling it would hide detail for nothing.
if (abs >= 1e7) {
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
}
if (abs >= 1e5) {
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
}
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
}
/**
* Move money into or out of a reserve account.
*
* The entry posts through the platform's own journal command, so it goes to maker-checker like
* any other entry against the ledger — the money is real, and a transfer that settles a statutory
* requirement is not a thing one person should be able to post alone.
*/
openTransfer(account: ReserveAccount): void {
const ref = this.dialog.open(ReserveTransferDialogComponent, {
data: {
from: account,
// Only the reserve accounts. A transfer out of the cash reserve into something that does
// not count toward it would quietly reduce what is maintained, and this screen exists to
// keep that figure up.
accounts: this.position?.accounts ?? [],
// The entry posts against the user's own office — the one whose books hold these accounts.
// Falls back to head office, which Fineract seeds as 1, rather than failing to open.
officeId: this.auth.getCredentials()?.officeId ?? 1,
currencyCode: 'INR'
},
autoFocus: false
});
ref.afterClosed().subscribe((posted: boolean) => {
if (posted) {
// The balances on screen are now a step behind the ledger. Read again rather than adjusting
// them here: the entry may have gone to maker-checker and not yet moved anything, and a
// screen that assumed it had would show a position the bank is not in.
this.rbi.getReservePosition().subscribe({
next: (data: ReservePosition) => (this.position = data),
error: () => undefined
});
}
});
}
close(): void {
@@ -0,0 +1,124 @@
<!--
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="rt">
<div class="rt-head">
<h2 class="rt-title">Transfer between reserve accounts</h2>
<button class="rt-x" (click)="close()" aria-label="Close">
<fa-icon icon="times"></fa-icon>
</button>
</div>
<!-- The two accounts, the way a desk thinks about them: money leaves one and arrives in the
other. The ledger's debit and credit are worked out when the entry posts, because asking a
treasury user which side to credit in order to move their own money is asking them to do the
ledger's job. -->
<div class="rt-from">
<div class="rt-field-label">From</div>
<div class="rt-acct">
<div class="rt-acct-name">{{ from.name }}</div>
<div class="rt-acct-sub">{{ from.glCode }} &middot; {{ from.item }} {{ from.head }}</div>
</div>
<div class="rt-bal">
<span class="rt-bal-label">Balance</span>
<span class="rt-bal-val">{{ money(from.balance) }}</span>
</div>
</div>
<div class="rt-arrow"><fa-icon icon="arrow-down"></fa-icon></div>
<div class="rt-row">
<label class="rt-field-label" for="rt-to">To</label>
<select id="rt-to" class="rt-input" [(ngModel)]="toAccountId">
<option [ngValue]="null">Choose the account to receive it</option>
@for (a of destinations; track a.glAccountId) {
<option [ngValue]="a.glAccountId">{{ a.glCode }} &middot; {{ a.name }} &mdash; {{ money(a.balance) }}</option>
}
</select>
</div>
<div class="rt-row">
<label class="rt-field-label" for="rt-amt">Amount to transfer</label>
<div class="rt-amount-wrap">
<span class="rt-rupee">&#8377;</span>
<input
id="rt-amt"
class="rt-input rt-amount"
type="number"
min="0"
step="0.01"
placeholder="0.00"
[(ngModel)]="amount"
/>
</div>
<!-- An account cannot send more than it holds: the ledger would carry a negative balance on a
bank account, which is not a position that exists. Said as the amount is typed rather than
on submit, so the figure is corrected before anyone commits to it. -->
@if (overdrawn) {
<div class="rt-warn">More than {{ from.name }} holds. It has {{ money(from.balance) }}.</div>
}
</div>
<!-- What each account would hold once this posts. A transfer is made to reach a balance, so the
balance it reaches is the thing worth showing before it is sent. -->
@if (to && (amount ?? 0) > 0 && !overdrawn) {
<div class="rt-after">
<div class="rt-after-row">
<span>{{ from.name }}</span>
<span
>{{ money(from.balance) }} &rarr; <strong>{{ money(fromAfter) }}</strong></span
>
</div>
<div class="rt-after-row">
<span>{{ to.name }}</span>
<span
>{{ money(to.balance) }} &rarr; <strong>{{ money(toAfter) }}</strong></span
>
</div>
</div>
}
<div class="rt-row">
<label class="rt-field-label" for="rt-ref">Reference <span class="rt-opt">optional</span></label>
<input id="rt-ref" class="rt-input" type="text" placeholder="NEFT / RTGS reference" [(ngModel)]="reference" />
</div>
<div class="rt-row">
<label class="rt-field-label" for="rt-note">Note <span class="rt-opt">optional</span></label>
<input
id="rt-note"
class="rt-input"
type="text"
placeholder="Why the money is being moved"
[(ngModel)]="comments"
/>
</div>
<!-- What this does and does not do. The entry is the bank's record of the movement; the money
reaches the other bank when the desk sends the NEFT or RTGS. A screen that posted silently
would leave someone believing the funds had moved. -->
<div class="rt-note">
<fa-icon icon="info-circle"></fa-icon>
This records the movement in the ledger and goes for approval. Send the NEFT or RTGS separately &mdash; the money
does not move until you do.
</div>
@if (error) {
<div class="rt-error">
<fa-icon icon="exclamation-triangle"></fa-icon>
{{ error }}
</div>
}
<div class="rt-actions">
<button mat-button (click)="close()">Cancel</button>
<button mat-raised-button color="primary" [disabled]="!canSubmit" (click)="submit()">
{{ submitting ? 'Posting…' : 'Post transfer' }}
</button>
</div>
</div>
@@ -0,0 +1,223 @@
/**
* 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/.
*/
$ink: #202124;
$ink2: #3c4043;
$ink3: #5f6368;
$border: #e2e8f0;
$primary: #0b5394;
$red: #c5221f;
$amber: #b06000;
$surface: #fff;
$mono: 'Roboto Mono', 'Courier New', monospace;
.rt {
padding: 20px 24px 16px;
min-width: 420px;
max-width: 520px;
}
.rt-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.rt-title {
margin: 0;
font-size: 17px;
font-weight: 600;
color: $ink;
}
.rt-x {
border: none;
background: none;
color: $ink3;
cursor: pointer;
padding: 2px 6px;
&:hover {
color: $ink;
}
}
.rt-field-label {
display: block;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.4px;
text-transform: uppercase;
color: $ink3;
margin-bottom: 6px;
}
.rt-opt {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
color: $ink3;
opacity: 0.75;
}
// The source account — fixed, since the dialog is opened from its row, so it reads as a statement
// rather than as another field to fill in.
.rt-from {
padding: 12px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #f8fafc;
}
.rt-acct-name {
font-size: 14px;
font-weight: 600;
color: $ink;
}
.rt-acct-sub {
font-size: 11px;
color: $ink3;
font-family: $mono;
margin-top: 2px;
}
.rt-bal {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed $border;
}
.rt-bal-label {
font-size: 11px;
color: $ink3;
}
.rt-bal-val {
font-family: $mono;
font-size: 14px;
font-weight: 600;
color: $ink;
font-variant-numeric: tabular-nums;
}
.rt-arrow {
text-align: center;
color: $primary;
margin: 10px 0;
}
.rt-row {
margin-bottom: 14px;
}
.rt-input {
width: 100%;
box-sizing: border-box;
padding: 9px 11px;
border: 1px solid $border;
border-radius: 6px;
font-size: 14px;
color: $ink2;
background: $surface;
&:focus {
outline: none;
border-color: $primary;
}
}
// The amount carries the rupee inside the field, so the figure is read as money without the sign
// being something anyone has to type.
.rt-amount-wrap {
position: relative;
}
.rt-rupee {
position: absolute;
left: 11px;
top: 50%;
transform: translateY(-50%);
color: $ink3;
font-size: 14px;
}
.rt-amount {
padding-left: 26px;
font-family: $mono;
font-variant-numeric: tabular-nums;
}
.rt-warn {
margin-top: 6px;
font-size: 12px;
color: $red;
}
// What each account would hold once this posts — a transfer is made to reach a balance, so the
// balance it reaches belongs on screen before it is sent.
.rt-after {
padding: 10px 14px;
margin-bottom: 14px;
border: 1px solid #cfe3d4;
border-radius: 8px;
background: #f1f8f3;
font-size: 12px;
}
.rt-after-row {
display: flex;
justify-content: space-between;
gap: 16px;
color: $ink2;
& + & {
margin-top: 5px;
}
strong {
font-family: $mono;
font-variant-numeric: tabular-nums;
}
}
// What the entry does and does not do: the money moves when the desk sends the NEFT, not when this
// posts, and a dialog that stayed silent would leave someone believing otherwise.
.rt-note {
display: flex;
gap: 8px;
padding: 10px 12px;
border-radius: 8px;
background: #fff8e6;
color: $amber;
font-size: 12px;
line-height: 1.45;
}
.rt-error {
display: flex;
gap: 8px;
margin-top: 12px;
padding: 10px 12px;
border-radius: 8px;
background: #fdecea;
color: $red;
font-size: 12px;
line-height: 1.45;
}
.rt-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 18px;
}
@@ -0,0 +1,167 @@
/**
* 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 { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { MAT_DIALOG_DATA, MatDialogRef, MatDialogModule } from '@angular/material/dialog';
import { MatButtonModule } from '@angular/material/button';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { RbiCircularsService } from '../../../rbi-circulars/rbi-circulars.service';
/** A reserve account as the position screen lists it. */
export interface TransferAccount {
glAccountId: number;
glCode: string;
name: string;
balance: number;
item: string;
head: string;
}
/** What the dialog is opened with: the account clicked, and everything it may move to. */
export interface ReserveTransferData {
from: TransferAccount;
accounts: TransferAccount[];
/** The office the entry posts against, and the date it posts on. */
officeId: number;
currencyCode: string;
}
/**
* Moves money between the accounts the cash reserve is held in.
*
* The entry posts through the platform's own journal command, so it goes to maker-checker like any
* other entry against the ledger. That is deliberate: the amounts settle a statutory requirement,
* and a transfer that changes whether the bank is compliant is not something one person should be
* able to post alone.
*
* It records the movement; it does not make it. The money reaches the other bank when the treasury
* desk sends the NEFT or RTGS, and this entry is the bank's own record of having done so — which is
* why the dialog says as much before it posts.
*/
@Component({
selector: 'mifosx-reserve-transfer-dialog',
standalone: true,
templateUrl: './reserve-transfer-dialog.component.html',
styleUrls: ['./reserve-transfer-dialog.component.scss'],
imports: [
CommonModule,
FormsModule,
MatDialogModule,
MatButtonModule,
FaIconComponent
]
})
export class ReserveTransferDialogComponent {
private dialogRef = inject(MatDialogRef<ReserveTransferDialogComponent>);
private rbi = inject(RbiCircularsService);
/** The account money leaves. Fixed — the dialog is opened from its row. */
readonly from: TransferAccount;
/** Everything it can move to: the other reserve accounts, never itself. */
readonly destinations: TransferAccount[];
toAccountId: number | null = null;
amount: number | null = null;
reference = '';
comments = '';
submitting = false;
error = '';
readonly data = inject<ReserveTransferData>(MAT_DIALOG_DATA);
constructor() {
this.from = this.data.from;
this.destinations = (this.data.accounts ?? []).filter((a) => a.glAccountId !== this.data.from.glAccountId);
}
/** The account chosen to receive the money, while one is chosen. */
get to(): TransferAccount | null {
return this.destinations.find((a) => a.glAccountId === this.toAccountId) ?? null;
}
/** What the source would hold once this posts — the figure that decides whether it may. */
get fromAfter(): number {
return this.from.balance - (this.amount ?? 0);
}
get toAfter(): number {
return (this.to?.balance ?? 0) + (this.amount ?? 0);
}
/**
* Whether the amount can actually be sent.
*
* An account cannot send more than it holds: the ledger would carry a negative balance on a cash
* or bank account, which is not a position that exists.
*/
get overdrawn(): boolean {
return this.amount !== null && this.amount > this.from.balance;
}
get canSubmit(): boolean {
return !this.submitting && this.toAccountId !== null && (this.amount ?? 0) > 0 && !this.overdrawn;
}
money(value: number | null | undefined): string {
if (value === null || value === undefined) {
return '—';
}
return `₹${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
}
/**
* Posts the transfer as a single journal entry.
*
* These are asset accounts, so the one money LEAVES is credited and the one it arrives in is
* debited — the opposite of how the screen words it, and deliberately so: a treasury user thinks
* "send from here, receive there", and making them think in debits and credits to move their own
* money would be asking them to do the ledger's job.
*/
submit(): void {
if (!this.canSubmit || !this.to) {
return;
}
this.submitting = true;
this.error = '';
// The reserve endpoint, not the manual journal entry one: that is turned off at the HTTP edge
// on this installation. The sides are named from and to, as a desk thinks of them; the debit
// and credit are worked out on the server, where the accounts can also be checked.
const transfer = {
fromGlAccountId: this.from.glAccountId,
toGlAccountId: this.to.glAccountId,
amount: this.amount as number,
officeId: this.data.officeId,
reference: this.reference || undefined,
comments:
this.comments || `Reserve transfer: ${this.from.glCode} ${this.from.name} to ${this.to.glCode} ${this.to.name}`
};
this.rbi.transferReserve(transfer).subscribe({
next: () => this.dialogRef.close(true),
error: (err: any) => {
this.submitting = false;
// The platform's own message, not a rewritten one: it names the rule that stopped the
// entry — a closed accounting period, a disabled account — and a generic "could not post"
// sends the user to look for a fault that has already been explained.
this.error =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.error?.errors?.[0]?.defaultUserMessage ||
'The transfer could not be posted.';
}
});
}
close(): void {
this.dialogRef.close(false);
}
}
@@ -38,12 +38,18 @@
>
<span class="metric-value">{{ crrSlr.crrRequired }}</span>
</div>
@if (crrSlr.crrGap) {
<div class="metric-gap" [class.metric-gap--short]="crrSlr.crrGap.short">{{ crrSlr.crrGap.text }}</div>
}
<div class="metric-row">
<span class="metric-label"
>SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }}</span
>
<span class="metric-value">{{ crrSlr.slrRequired }}</span>
</div>
@if (crrSlr.slrGap) {
<div class="metric-gap" [class.metric-gap--short]="crrSlr.slrGap.short">{{ crrSlr.slrGap.text }}</div>
}
</div>
<!-- "Within Limits" is a compliance statement, so it is made only where the return actually
says so. Where it cannot compute, the card says that instead of passing the bank. -->
@@ -899,3 +899,17 @@ $purple: #7c3aed;
text-align: center;
}
}
// What is held against a requirement, under the requirement itself. Surplus is quiet; a shortfall is
// red, because on a compliance card it is the one line that must not be read past.
.metric-gap {
margin: -2px 0 8px;
text-align: right;
font-size: 11px;
color: #137333;
}
.metric-gap--short {
color: #c5221f;
font-weight: 700;
}
+48 -5
View File
@@ -61,8 +61,19 @@ export class DashboardComponent implements OnInit {
slrRate: number | null;
crrRequired: string;
slrRequired: string;
/** Surplus, or the shortfall, per ratio. A shortfall is its own amount, not a negative surplus. */
crrGap: { text: string; short: boolean } | null;
slrGap: { text: string; short: boolean } | null;
status: 'ok' | 'short' | 'unknown';
} = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
} = {
crrRate: null,
slrRate: null,
crrRequired: '—',
slrRequired: '—',
crrGap: null,
slrGap: null,
status: 'unknown'
};
/** Array of all user activities */
userActivity: string[];
@@ -93,8 +104,27 @@ export class DashboardComponent implements OnInit {
/** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
private loadCrrSlr(): void {
const crore = (v: number | null | undefined): string =>
v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
// Rupees, not crore. Rounding to crore on a bank this size collapsed the figures to "₹2 Cr"
// and "₹13 Cr" — two requirements a crore apart both read the same, and a tile a treasury desk
// glances at said nothing it could act on.
// Scaled to the Indian crore/lakh so the tile holds its figure however large the bank grows.
// Plain rupees ran to nineteen characters on a seventy-crore requirement and overflowed the
// card; rounding to crore outright collapsed two requirements a crore apart to the same "₹2
// Cr". This keeps four or five characters and two decimals of precision at any scale.
const money = (v: number | null | undefined): string => {
if (v === null || v === undefined) {
return '—';
}
const abs = Math.abs(v);
const sign = v < 0 ? '-' : '';
if (abs >= 1e7) {
return `${sign}₹${(abs / 1e7).toLocaleString('en-IN', { maximumFractionDigits: 2 })} Cr`;
}
if (abs >= 1e5) {
return `${sign}₹${(abs / 1e5).toLocaleString('en-IN', { maximumFractionDigits: 2 })} L`;
}
return `${sign}₹${Math.round(abs).toLocaleString('en-IN')}`;
};
// The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
// the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
@@ -107,8 +137,8 @@ export class DashboardComponent implements OnInit {
}
this.crrSlr.crrRate = data?.crrPercentage ?? null;
this.crrSlr.slrRate = data?.slrPercentage ?? null;
this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
this.crrSlr.crrRequired = money(data?.partB?.required?.[latest]);
this.crrSlr.slrRequired = money(data?.partC?.required?.[latest]);
// Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
// bank short on SLR is not within limits because its cash reserve happens to be ample.
@@ -116,6 +146,19 @@ export class DashboardComponent implements OnInit {
const crrNeed = data?.partB?.required?.[latest];
const slrHeld = data?.partC?.maintained?.[latest];
const slrNeed = data?.partC?.required?.[latest];
// Each ratio's own standing, under its requirement: surplus where it is met, and the
// shortfall — in red, named as one — where it is not. A single "Shortfall" tag said the bank
// was short without saying which ratio or by how much, which is the first thing asked.
const gap = (held: number | null | undefined, need: number | null | undefined) => {
if (held === null || held === undefined || need === null || need === undefined) {
return null;
}
const short = held < need;
return { text: `${short ? 'Short' : 'Surplus'} ${money(Math.abs(held - need))}`, short };
};
this.crrSlr.crrGap = gap(crrHeld, crrNeed);
this.crrSlr.slrGap = gap(slrHeld, slrNeed);
if ([
crrHeld,
crrNeed,