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
@@ -129,11 +129,30 @@
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<div class="rrc-gl-head">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<!--
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 of them at once buries the few that matter for the task in hand.
-->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-gl-head__filter">
<mat-label>Configuring</mat-label>
<mat-select [(ngModel)]="formFilter" (selectionChange)="onFormFilterChange()">
@for (f of formFilters; track f.value) {
<mat-option [value]="f.value">{{ f.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</div>
<p class="rrc-card__hint">
Every role takes as many accounts as it needs — a bank holds current accounts at several other banks, and each
one counts toward what it maintains. Only postable accounts are listed: a header account's balance is the sum of
its children, so mapping both would count those children twice.
@if (formFilter !== 'ALL') {
Heads that build or net the DTL base are shown for either return, since both charge their rate on that same
base.
}
</p>
@if (unmappedCount) {
@@ -385,6 +385,20 @@ $red: #c5221f;
margin: 0 !important;
}
// Title and the return filter on one line: the filter says what the list below is showing, so it
// belongs beside the heading rather than buried among the roles.
.rrc-gl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
flex-wrap: wrap;
&__filter {
width: 260px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
@@ -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) {
@@ -61,12 +61,7 @@
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
<button mat-raised-button color="primary" [disabled]="savingGl || !isGlDirty(role)" (click)="saveGl(role)">
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
@@ -78,8 +73,8 @@
<div class="rrc-card">
<h3 class="rrc-card__title">Financial years</h3>
<p class="rrc-card__hint">
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep quoting
that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
One entry per year, because the Finance Act fixes TDS per year and a return for an earlier year must keep
quoting that year's figures. Add the next year before 01 April, or TDS screens stop computing on that date.
</p>
<div class="rrc-tabs" role="tablist">
@@ -37,7 +37,10 @@ interface FieldDef {
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
styleUrls: [
'../rbi-reserve-config/rbi-reserve-config.component.scss',
'./tds-config.component.scss'
],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
@@ -67,7 +70,12 @@ export class TdsConfigComponent implements OnInit {
readonly rateFields: FieldDef[] = [
{ key: 'thresholdGeneral', label: 'Threshold – general (₹)', kind: 'amount', hint: 'Payee below 60' },
{ key: 'thresholdSenior', label: 'Threshold – senior citizen (₹)', kind: 'amount', hint: '60 at any time in the year' },
{
key: 'thresholdSenior',
label: 'Threshold – senior citizen (₹)',
kind: 'amount',
hint: '60 at any time in the year'
},
{ key: 'ratePanPercentage', label: 'Rate – valid PAN (%)', kind: 'percent' },
{ key: 'rateNoPanPercentage', label: 'Rate – PAN missing / invalid (%)', kind: 'percent' },
{ key: 'lateDeductionPercentage', label: 'Late deduction interest (% per month)', kind: 'percent' },
@@ -96,7 +104,12 @@ export class TdsConfigComponent implements OnInit {
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{ key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
{
key: 'declarationStatementDueDay',
label: 'Statement due on day',
kind: 'int',
hint: 'Of the following month / quarter'
}
];
readonly statuteFields: FieldDef[] = [