/** * 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 = {}; /** 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(); 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; } }