Files
cbsnexora/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
T
keshavandClaude Opus 5.5 b6994c0b76 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>
2026-10-01 13:20:01 +05:30

573 lines
22 KiB
TypeScript

/**
* 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 { FormsModule } from '@angular/forms';
import { catchError, forkJoin, of } from 'rxjs';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { OrganizationService } from '../organization.service';
/**
* One GL account pinned to a role: which account, how much of its balance the role takes, and - on a
* liability head - whether that portion is a demand or a time liability.
*/
export interface ReserveMapping {
glAccountId: number;
weightPercentage: number;
liabilityClass: string | null;
}
/**
* Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
* accounts the returns read their figures from.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
* configured and reports nothing — so the screen states, per role, whether it is mapped at all.
*
* The accounts cannot be shipped as defaults: this platform seeds two mutually exclusive charts of
* accounts and real tenants match neither exactly, so cash on hand is 100101 on one chart and
* 101001 on another. Picking one here would read as zero on the charts it does not match.
*/
@Component({
selector: 'mifosx-rbi-reserve-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
* Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
* and Form 9, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
* and a single list makes each one harder to find than it is to make.
*/
protected readonly group: string | null = 'RESERVE';
/** Whether the CRR/SLR percentages belong on this screen. Only the reserve returns have rates. */
protected readonly showRates: boolean = true;
glRoles: any[] = [];
/** 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[] = [];
glAccountOptions: any[] = [];
loading = true;
savingKey: string | null = null;
savingRate = false;
errorMsg = '';
successMsg = '';
/**
* Draft of the per-role mapping, keyed by role. Committed on Save.
*
* A mapping is an account plus the portion of it the role takes and, on a liability head, which
* head that portion falls under. It is not a plain list of account ids because one account can
* appear twice under one role: RBI treats part of a savings balance as a demand liability and the
* rest as time, which is two rows against the same account.
*/
selection: Record<string, ReserveMapping[]> = {};
/** The heads a liability can fall under, and the label each carries on screen. */
readonly liabilityClasses = [
{ value: 'DEMAND', label: 'Demand' },
{ value: 'TIME', label: 'Time' }
];
/**
* How a head an administrator adds is consumed by the return.
*
* MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
* met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
* both would let a head meant for Form 1 quietly satisfy Form 9.
*/
readonly roleUsages = [
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
{ 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' }
];
/** New-head form. */
newRole: { label: string; description: string; usage: string } = { label: '', description: '', usage: 'LIABILITY' };
addingRole = false;
showAddRole = false;
/** New-rate form. Defaults to today so the common case is one edit and Save. */
newRate: { rateType: string; percentage: number | null; effectiveFrom: string } = {
rateType: 'CRR',
percentage: null,
effectiveFrom: new Date().toISOString().slice(0, 10)
};
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
forkJoin({
config: this.organizationService.getRbiReserveConfig().pipe(catchError(() => of(null))),
template: this.organizationService.getRbiReserveConfigTemplate().pipe(catchError(() => of(null)))
}).subscribe(({ config, template }: any) => {
this.loading = false;
if (!config) {
this.errorMsg = 'Could not load RBI reserve configuration.';
return;
}
this.applyConfig(config);
this.glAccountOptions = template?.glAccountOptions || [];
});
}
private applyConfig(config: any): void {
// 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.rolesForScreen();
this.rates = config.rates || [];
this.selection = {};
for (const role of this.glRoles) {
// Read back what is stored rather than re-derived: a weight or a class the administrator set
// deliberately would otherwise be replaced by a default on the next save.
this.selection[role.configKey] = (role.accounts || []).map((a: any) => ({
glAccountId: a.glAccountId,
weightPercentage: a.weightPercentage === null || a.weightPercentage === undefined ? 100 : +a.weightPercentage,
liabilityClass: a.liabilityClass ?? (role.classified ? 'TIME' : null)
}));
}
this.rebuildGroups();
}
/**
* The roles split by the return they feed, so one screen can configure four returns without
* presenting thirty-four roles as a single list where the four that drive CRR are lost among
* the thirty that do not.
*
* Built from the group the backend states rather than from a key prefix: a role's grouping is
* part of what the return means, and duplicating that rule here would let the two drift.
*/
private rebuildGroups(): void {
const byGroup = new Map<string, { label: string; roles: any[] }>();
for (const role of this.glRoles) {
const group = role.group || 'OTHER';
if (!byGroup.has(group)) {
byGroup.set(group, { label: role.groupLabel || group, roles: [] });
}
byGroup.get(group)!.roles.push(role);
}
this.roleGroups = [...byGroup.entries()].map(
([
name,
entry
]) => ({ name, label: entry.label, roles: entry.roles })
);
// Keep the open tab if it still exists, so saving a role does not throw the user back to the
// first tab with their place lost.
if (!this.roleGroups.some((g) => g.name === this.activeGroup)) {
this.activeGroup = this.roleGroups[0]?.name ?? '';
}
}
/** Tabs only earn their place when the screen shows more than one return's roles. */
get showTabs(): boolean {
return this.roleGroups.length > 1;
}
/** Heading and blurb, overridden per return by the screens scoped to one. */
get screenTitle(): string {
return 'RBI Reserve Configuration';
}
get screenSubtitle(): string {
return (
'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
'than reporting a nil figure that looks real.'
);
}
/** A role with nothing mapped makes its return refuse to compute — surfaced, not hidden. */
isUnmapped(role: any): boolean {
return !(this.selection[role.configKey] || []).length;
}
get unmappedCount(): number {
return this.glRoles.filter((r) => this.isUnmapped(r)).length;
}
/** Unmapped roles within one group, so each tab carries its own count. */
unmappedIn(group: { roles: any[] }): number {
return group.roles.filter((r) => this.isUnmapped(r)).length;
}
showGroup(name: string): void {
this.activeGroup = name;
}
get visibleRoles(): any[] {
return this.roleGroups.find((g) => g.name === this.activeGroup)?.roles ?? [];
}
/** True once the draft differs from what is stored, so Save is only offered when useful. */
isDirty(role: any): boolean {
const key = (m: any) => `${m.glAccountId}/${m.liabilityClass ?? ''}/${+m.weightPercentage}`;
const stored = (role.accounts || [])
.map((a: any) => key({ ...a, weightPercentage: a.weightPercentage ?? 100 }))
.sort();
const draft = (this.selection[role.configKey] || []).map(key).sort();
return stored.length !== draft.length || stored.some((k: string, i: number) => k !== draft[i]);
}
/** Adds an empty row for this role, for the administrator to pick an account into. */
addMapping(role: any): void {
// Defaults to the whole balance, and to Time on a liability head. Understating demand is the
// conservative default: demand attracts the heavier treatment, so a forgotten class cannot make
// a bank look compliant when it is not.
this.selection[role.configKey] = [
...(this.selection[role.configKey] || []),
{ glAccountId: null as any, weightPercentage: 100, liabilityClass: role.classified ? 'TIME' : null }];
}
removeMapping(role: any, index: number): void {
this.selection[role.configKey] = (this.selection[role.configKey] || []).filter((_, i) => i !== index);
}
/**
* Accounts still selectable in a given row: everything not already used by ANOTHER row under the
* same head. The same account under a different head is left available, because a demand slice and
* a time slice of one savings account are two different figures, not a duplicate.
*/
availableAccounts(role: any, index: number): any[] {
const rows = this.selection[role.configKey] || [];
const taken = new Set(rows.filter((_, i) => i !== index).map((m) => `${m.glAccountId}/${m.liabilityClass ?? ''}`));
const current = rows[index];
return this.glAccountOptions.filter((o) => !taken.has(`${o.id}/${current?.liabilityClass ?? ''}`));
}
/** A row that cannot be saved - no account chosen, or a portion outside 0-100. */
isRowInvalid(mapping: ReserveMapping): boolean {
const weight = +mapping.weightPercentage;
return !mapping.glAccountId || isNaN(weight) || weight < 0 || weight > 100;
}
/** Whether any row of this role is incomplete, so Save is withheld rather than failing server-side. */
hasInvalidRows(role: any): boolean {
return (this.selection[role.configKey] || []).some((m) => this.isRowInvalid(m));
}
/**
* The demand and time subtotals this role's rows declare, as a share of the balances behind them.
*
* Shown because a split is easy to get wrong in a way no single row looks wrong in: two rows
* against one account that together take 90% of it silently drop a tenth of the balance out of the
* base, and only the total reveals it.
*/
weightTotalFor(role: any, glAccountId: number): number {
return (this.selection[role.configKey] || [])
.filter((m) => m.glAccountId === glAccountId)
.reduce((sum, m) => sum + (+m.weightPercentage || 0), 0);
}
/** Accounts split across more than one row whose portions do not add up to the whole balance. */
unbalancedAccounts(role: any): { glAccountId: number; total: number }[] {
const rows = this.selection[role.configKey] || [];
const ids = [...new Set(rows.map((m) => m.glAccountId).filter((id) => !!id))];
return ids
.map((id) => ({ glAccountId: id, total: this.weightTotalFor(role, id) }))
.filter((e) => Math.abs(e.total - 100) > 0.0001);
}
saveRole(role: any): void {
this.savingKey = role.configKey;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.updateRbiReserveGlRole(
role.configKey,
(this.selection[role.configKey] || []).map((m) => ({
glAccountId: m.glAccountId,
weightPercentage: +m.weightPercentage,
// Sent as null off a liability head, so a class left over from a head that was retyped
// cannot travel with a figure the split does not apply to.
liabilityClass: role.classified ? m.liabilityClass : null
}))
)
.pipe(
catchError((err: any) => {
this.savingKey = null;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this mapping.';
return of(null);
})
)
.subscribe((res: any) => {
this.savingKey = null;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.roleLabel(role)} updated.`;
});
}
saveRate(): void {
if (this.newRate.percentage === null || !this.newRate.effectiveFrom) {
this.errorMsg = 'Enter a percentage and an effective date.';
return;
}
this.savingRate = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.upsertRbiReserveRate(this.newRate.rateType, this.newRate.percentage, this.newRate.effectiveFrom)
.pipe(
catchError((err: any) => {
this.savingRate = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this rate.';
return of(null);
})
)
.subscribe((res: any) => {
this.savingRate = false;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.newRate.rateType} rate saved.`;
this.newRate.percentage = null;
});
}
deleteRate(rate: any): void {
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.deleteRbiReserveRate(rate.id)
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this rate.';
return of(null);
})
)
.subscribe((res: any) => {
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = 'Rate removed.';
});
}
/** The rate a return filed today would use — the latest one already in effect. */
currentRate(rateType: string): any | null {
const today = new Date().toISOString().slice(0, 10);
const inForce = this.rates.filter((r) => r.rateType === rateType && r.effectiveFrom <= today);
return inForce.length ? inForce[0] : null;
}
ratesFor(rateType: string): any[] {
return this.rates.filter((r) => r.rateType === rateType);
}
/** A future-dated rate is on record but not yet applied — worth saying so explicitly. */
isFuture(rate: any): boolean {
return rate.effectiveFrom > new Date().toISOString().slice(0, 10);
}
/**
* A role's display name and how the return uses it, both as the backend states them.
*
* Taken from the response rather than from a table here: a role only exists because code
* computes something from it, and that code is what knows what it means. A copy in the screen
* would leave a newly added role showing as a raw key.
*/
roleLabel(role: any): string {
return role?.label || role?.configKey || '';
}
/** Which return each role feeds, so the consequence of leaving one unmapped is visible. */
roleUsedBy(role: any): string {
return role?.usedBy || '';
}
glLabel(accountId: number): string {
const account = this.glAccountOptions.find((a) => a.id === accountId);
return account ? `${account.glCode} — ${account.name}` : `#${accountId}`;
}
// ── heads an administrator adds ──
/**
* Adds a head to this return. The bank's own chart decides what these are - a reserve, a tax
* payable, bills payable - so they are rows rather than a fixed list in code.
*/
addRole(): void {
if (!this.newRole.label.trim()) {
this.errorMsg = 'Give the head a name.';
return;
}
this.addingRole = true;
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.createRbiReserveRole({
label: this.newRole.label.trim(),
description: this.newRole.description.trim(),
// Added to the return this screen configures, so a head cannot land on a form the
// administrator is not looking at.
group: this.group ?? this.activeGroup,
usage: this.newRole.usage
})
.pipe(
catchError((err: any) => {
this.addingRole = false;
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not add this head.';
return of(null);
})
)
.subscribe((res: any) => {
this.addingRole = false;
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = `${this.newRole.label.trim()} added. Map its GL accounts below.`;
this.newRole = { label: '', description: '', usage: 'LIABILITY' };
this.showAddRole = false;
});
}
/**
* Removes a head and everything mapped to it. Confirmed first: the figures it contributed leave
* the return with it, so a return filed after this will not agree with one filed before.
*/
deleteRole(role: any): void {
if (!confirm(`Remove "${this.roleLabel(role)}" and its mapped accounts? Returns will stop counting it.`)) {
return;
}
this.errorMsg = '';
this.successMsg = '';
this.organizationService
.deleteRbiReserveRole(role.roleId)
.pipe(
catchError((err: any) => {
this.errorMsg = err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not remove this head.';
return of(null);
})
)
.subscribe((res: any) => {
if (!res) {
return;
}
this.applyConfig(res);
this.successMsg = 'Head removed.';
});
}
/** How a head is consumed, in words, for the screen to state next to it. */
usageLabel(usage: string): string {
return this.roleUsages.find((u) => u.value === usage)?.label ?? usage;
}
}