feat: update TDS config, RBI reserve returns, current products, and dormancy reports
This commit is contained in:
@@ -0,0 +1,485 @@
|
||||
/**
|
||||
* 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 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. */
|
||||
readonly roleUsages = [
|
||||
{ value: 'LIABILITY', label: 'Liability — adds to the DTL base' },
|
||||
{ value: 'ASSET_NET', label: 'Asset — netted off the base (NDTL)' },
|
||||
{ value: 'MAINTAINED', label: 'Counts toward what is maintained' },
|
||||
{ value: 'MEMO', label: 'Memo — shown 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 {
|
||||
const 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.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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user