/** * 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, OnInit, inject } from '@angular/core'; import { CommonModule, DecimalPipe } from '@angular/common'; 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 { sl: string; name: string; sub: string; code: string; opening: string; current: string; /** The figures themselves, kept so totals add from values rather than from formatted text. */ openingValue: number | null; currentValue: number | null; change: string; changeDir: 'up' | 'dn' | 'flat'; 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, templateUrl: './crr-slr-dialog.component.html', styleUrls: ['./crr-slr-dialog.component.scss'], imports: [ CommonModule, DecimalPipe, MatDialogModule, MatButtonModule, MatTabsModule, MatTooltipModule, FaIconComponent ] }) export class CrrSlrDialogComponent implements OnInit { private dialogRef = inject(MatDialogRef); private rbi = inject(RbiCircularsService); private dialog = inject(MatDialog); private auth = inject(AuthenticationService); 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 = ''; /** * The close the ratios are charged on — the second preceding fortnight, not the one reported. * * Named on screen because the gap between the two is the whole of how the rules work: a bank * cannot hold a reserve against liabilities it does not yet know, so the requirement is fixed on * a position already struck and reported. A screen showing one date invites a reader to think the * requirement was worked out on the day it is shown against. */ baseDateStr = ''; loading = true; /** Set when the return could not be computed — and why, in the return's own words. */ unavailable = ''; /** * The NDTL computation, read from the return rather than stated here. * * These were fixed numbers until now — a demonstration screen that showed ₹69,221 Cr whatever the * ledger held. The figures are the bank's own regulatory position, so a plausible-looking number * is worse than no number: it reads as a reported figure and cannot be told apart from one. * * Null until the return answers, and left null where it cannot compute. The template shows a dash * for a null, which says "not reported" where a zero would say "reported as nil". */ 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; slrRequired: number | null = null; totalLiquidAssets: number | null = null; slrActualPercent: number | null = null; slrSurplus: number | null = null; totalReserve: number | null = null; /** * A figure for display, or a dash where the return did not carry one. * * 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. */ rupees(value: number | null): string { if (value === null) { return '—'; } return `₹${value.toLocaleString('en-IN', { maximumFractionDigits: 0 })}`; } ngOnInit(): void { // 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 // struck 45 days back by a lag rule that belongs to the other report. Those two disagree by // weeks, and the 45-day one lands mid-fortnight on a date the bank never reported. this.rbi.getForm1Statutory().subscribe({ next: (data: any) => { this.loading = false; // The later of the two closes the return reports — the one the bank most recently filed. const columns: string[] = data?.columns ?? []; const latest = columns.length - 1; this.reportingDateStr = columns[latest] ?? ''; this.baseDateStr = data?.baseColumns?.[latest] ?? ''; this.crrRate = data?.crrPercentage ?? null; this.slrRate = data?.slrPercentage ?? null; if (!columns.length) { this.unavailable = data?.unmetRequirements?.[0] ?? 'Form 1 could not be computed. Check the CRR/SLR configuration in Admin.'; return; } // The return computes but says something would make it wrong to file — a rate never set, a // base date the ledger cannot answer. Shown rather than swallowed: the reason is the thing // an administrator needs in order to fix it. this.unavailable = data?.unmetRequirements?.[0] ?? ''; // 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 want = startsWith.toUpperCase(); const line = lines.find( (l: any) => Array.isArray(l?.values) && String(l?.label ?? '') .toUpperCase() .startsWith(want) ); return line?.values?.[latest] ?? null; }; // 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); } // 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.baseNdtl) * 100).toFixed(2); } if (this.crrRequired !== null && this.slrRequired !== null) { this.totalReserve = +(this.crrRequired + this.slrRequired).toFixed(2); } this.fillTabs(lines, latest); }, error: () => { this.loading = false; this.unavailable = 'The CRR/SLR position could not be read. The return service did not respond.'; } }); } /** * The four tabs, filled from the return's own lines. * * These were four fixed lists until now — 435 lines of invented accounts with invented balances, * which a reader had no way to tell apart from the bank's real position. They are now built from * the accounts the return actually read, so a tab is empty exactly when the bank has nothing * mapped under that head, and says so. */ demandLiabilities: CrrSlrRow[] = []; timeLiabilities: CrrSlrRow[] = []; bankingAssets: CrrSlrRow[] = []; liquidAssets: CrrSlrRow[] = []; /** * Turns Part A's lines into the rows a tab shows. * * The form reports each head at both of the closes it carries, so the earlier column is the * opening and the later one the current — the same pair these tables have always shown, now taken * from the form rather than invented. Headings and totals are skipped: a heading carries no * figure, and a total would be added a second time by the footer below. */ private rowsFrom(lines: any[], latest: number, match: (label: string) => boolean): CrrSlrRow[] { const rows: CrrSlrRow[] = []; for (const line of lines ?? []) { const label = String(line?.label ?? ''); if (line?.heading || line?.total || !line?.values || !match(label)) { continue; } const opening = latest > 0 ? (line.values[latest - 1] ?? null) : null; const current = line.values[latest] ?? null; const change = opening === null || current === null ? null : current - opening; rows.push({ sl: String(rows.length + 1).padStart(2, '0'), // 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(), // 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.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' }); } return rows; } /** * A tab's column total, added from the rows on screen. * * Added from the rows rather than stated separately, so the foot of a table always agrees with * what is above it. The footers used to be fixed text and agreed with nothing. * * Summed from each row's own figure, not from the text it displays: a total parsed back out of * formatted strings would quietly lose the paise the display rounds away. */ 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.rupees(values.reduce((a, b) => a + b, 0)) : '—'; } /** The movement across a tab, between the two closes the form reports. */ totalChange(rows: CrrSlrRow[]): string { const opening = rows.map((r) => r.openingValue).filter((v): v is number => v !== null); const current = rows.map((r) => r.currentValue).filter((v): v is number => v !== null); if (!opening.length || !current.length) { return '—'; } const change = current.reduce((a, b) => a + b, 0) - opening.reduce((a, b) => a + b, 0); return `${change >= 0 ? '+' : ''}${this.rupees(change).replace('₹', '')}`; } /** Fills the four tabs from one reading of the form. */ private fillTabs(lines: any[], latest: number): void { const has = (label: string, ...words: string[]): boolean => { const l = label.toLowerCase(); return words.some((w) => l.includes(w)); }; // 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. // 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) ); // 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 { this.dialogRef.close(); } }