feat(rbi-reserve,savings): Form 1/Form 9 returns UI, branch-wise interest posting reports and summary

- Reports > Interest Posting Reports: per branch and product, generate/regenerate
  at any time, approve; branch column, HO-on-behalf marker, posted txn per account
- Reports > Interest Posting Summary (branch-wise) for the head office, with Excel export
- Day Close warns and disables Close Day while the branch's interest report is unapproved
- Savings product accounting: Interest On Savings (expense), Savings Interest Payable
  and Paid (liability rows, required)
- RBI Form 1 statutory and Form 9 return screens, reserve config updates
- Interest posting report GETs exempted from the response cache

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-01 13:20:01 +05:30
co-authored by Claude Opus 5.5
parent 4f02f24aae
commit b6994c0b76
39 changed files with 4407 additions and 245 deletions
@@ -64,6 +64,83 @@ export class RbiReserveConfigComponent implements OnInit {
/** The roles grouped by the return they feed, in backend order. */
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
* Which return's configuration is on screen: Form 1, Form 9, or both together.
*
* The two returns share a base but maintain their requirement in different assets, so an
* administrator setting one up is looking at a different set of heads than one setting up the
* other. Showing all twenty-six at once buries the four that matter for the task in hand.
*/
formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
/** Re-slices what is on screen. No round trip - the full set is already held. */
onFormFilterChange(): void {
this.glRoles = this.rolesForScreen();
this.rebuildGroups();
}
/**
* The roles this screen shows: those in its own group, narrowed to the return selected above.
*
* The group scope is widened for Form IX. Its heads were seeded into their own group so they sit
* apart on the backend, and a screen fixed to 'RESERVE' would filter every one of them out before
* the return filter ever ran - leaving the Form IX option selectable and its list empty.
*/
private rolesForScreen(): any[] {
const wanted = this.formFilter === 'FORM_IX' ? 'FORM9' : this.group;
const scoped = wanted ? this.allRoles.filter((r: any) => r.group === wanted) : this.allRoles;
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
{ value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
{ value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
* Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
* cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
* 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
* on that same base. A memo head is stated on either return and so shows on both.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
return true;
}
// Form IX's heads are placed by the group they were seeded into, not by usage. Its usages -
// LIABILITY and ASSET - read the same as Form 1's but mean something different: on Form IX they
// say which SIDE OF THE BALANCE SHEET a head sits on, not how it enters a ratio. Sixty heads
// showing up under Form 1's filter would bury the eight that belong there.
const group = role.group || role.roleGroup;
if (group === 'FORM9') {
return this.formFilter === 'FORM_IX';
}
if (this.formFilter === 'FORM_IX') {
return false;
}
// The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
// so it is placed by name; every other head is placed by how the return consumes it.
if (role.configKey === 'SLR_INVESTMENTS') {
return this.formFilter === 'FORM_9';
}
const usage = role.usage || role.roleUsage;
if (usage === 'MAINTAINED') {
return this.formFilter === 'FORM_1';
}
if (usage === 'SLR_ASSET') {
return this.formFilter === 'FORM_9';
}
// LIABILITY, ASSET_NET and MEMO all appear on both returns.
return true;
}
/** Which group's roles are on screen. Unused when the screen is scoped to one group. */
activeGroup = '';
rates: any[] = [];
@@ -103,6 +180,9 @@ export class RbiReserveConfigComponent implements OnInit {
{ value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
{ value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
{ value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
{ value: 'MEMO', label: 'Memo — shown on the return but totals nothing' }
];
@@ -140,10 +220,11 @@ export class RbiReserveConfigComponent implements OnInit {
}
private applyConfig(config: any): void {
const allRoles = config.glRoles || [];
// Kept so the form filter can re-slice without a round trip.
this.allRoles = config.glRoles || [];
// Scoped to this screen's return before anything else, so every count, warning and Save on the
// page speaks about the roles the user can actually see.
this.glRoles = this.group ? allRoles.filter((r: any) => r.group === this.group) : allRoles;
this.glRoles = this.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {