feat: update TDS config, RBI reserve returns, current products, and dormancy reports

This commit is contained in:
2026-09-24 18:07:54 +05:30
parent 1eaa8cc8cf
commit be35f3cbc3
66 changed files with 5474 additions and 565 deletions
@@ -261,6 +261,32 @@ const routes: Routes = [
import('./clearing-gl-config/clearing-gl-config.component').then((m) => m.ClearingGlConfigComponent),
data: { title: 'Clearing GL Configuration', breadcrumb: 'Clearing GL Configuration' }
},
{
// CRR/SLR percentages and the GL accounts RBI Form 1 and Form 9 read their figures from.
path: 'rbi-reserve-config',
loadComponent: () =>
import('./rbi-reserve-config/rbi-reserve-config.component').then((m) => m.RbiReserveConfigComponent),
data: { title: 'RBI Reserve Configuration', breadcrumb: 'RBI Reserve Configuration' }
},
{
// TDS thresholds, rates and statutory references per FY, and the TDS payable GL.
path: 'tds-config',
loadComponent: () => import('./tds-config/tds-config.component').then((m) => m.TdsConfigComponent),
data: { title: 'TDS Configuration', breadcrumb: 'TDS Configuration' }
},
{
// OSS 1 keeps its own screen: sixteen roles of its own, none shared with CRR or SLR.
path: 'oss1-gl-config',
loadComponent: () =>
import('./rbi-reserve-config/oss1-gl-config.component').then((m) => m.Oss1GlConfigComponent),
data: { title: 'OSS 1 GL Configuration', breadcrumb: 'OSS 1 GL Configuration' }
},
{
path: 'oss2-gl-config',
loadComponent: () =>
import('./rbi-reserve-config/oss2-gl-config.component').then((m) => m.Oss2GlConfigComponent),
data: { title: 'OSS 2 GL Configuration', breadcrumb: 'OSS 2 GL Configuration' }
},
{
path: 'employees',
data: { title: 'Manage Employees', breadcrumb: 'Manage Employees' },
@@ -76,6 +76,64 @@
</a>
</div>
<!-- RBI Reserve Configuration -->
<div #rbiReserveConfig>
<a class="org-card" [routerLink]="['rbi-reserve-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-percent"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">RBI Reserve Configuration</h3>
<p class="org-card__desc">CRR and SLR rates, and the GL accounts behind Form 1 and Form 9</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- TDS Configuration -->
<div>
<a class="org-card" [routerLink]="['tds-config']" *mifosxHasPermission="'READ_TDSCONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-receipt"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">TDS Configuration</h3>
<p class="org-card__desc">TDS thresholds and rates per financial year, and the TDS payable GL</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 1 GL Configuration -->
<div #oss1GlConfig>
<a class="org-card" [routerLink]="['oss1-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-scale-balanced"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 1 GL Configuration</h3>
<p class="org-card__desc">
GL accounts behind OSS 1 — Return on Assets and Liabilities, read as closing balances
</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- OSS 2 GL Configuration -->
<div #oss2GlConfig>
<a class="org-card" [routerLink]="['oss2-gl-config']" *mifosxHasPermission="'READ_RBIRESERVECONFIG'">
<div class="org-card__icon org-card__icon--amber">
<i class="fas fa-chart-line"></i>
</div>
<div class="org-card__body">
<h3 class="org-card__title">OSS 2 GL Configuration</h3>
<p class="org-card__desc">GL accounts behind OSS 2 — Return on Earnings, read as quarterly movement</p>
</div>
<i class="fas fa-chevron-right org-card__arrow"></i>
</a>
</div>
<!-- Currency Configuration -->
<div #addEditCurrency>
<a class="org-card" [routerLink]="['currencies']" *mifosxHasPermission="'READ_CURRENCY'">
@@ -935,4 +935,71 @@ export class OrganizationService {
updateClearingGlConfig(configKey: string, glAccountId: number | null): Observable<any> {
return this.http.put(`/clearingglconfig/${configKey}`, { glAccountId });
}
// ── RBI Reserve Configuration (Form 1 CRR / Form 9 SLR) ──
/**
* @returns {Observable<any>} `{ rates[], glRoles[] }` — the effective-dated CRR/SLR percentages
* and the GL accounts mapped to each reserve role. Roles with nothing mapped are included, so
* the screen can show the gaps rather than hide them.
*/
getRbiReserveConfig(): Observable<any> {
return this.http.get('/rbireserveconfig');
}
/** @returns {Observable<any>} Selectable (postable, non-header) GL accounts plus the rate types. */
getRbiReserveConfigTemplate(): Observable<any> {
return this.http.get('/rbireserveconfig/template');
}
/**
* Replaces the entire set of GL accounts mapped to one reserve role.
*
* Each mapping carries the portion of the account's balance the role takes and, on a liability
* head, whether that portion is a demand or a time liability. RBI treats part of a savings balance
* as demand and the rest as time, so the same account is sent twice under one role with different
* classes - which is why this is a list of mappings rather than a list of account ids.
*
* @param {string} configKey A built-in role (CASH_IN_HAND, BALANCE_WITH_OTHER_BANKS,
* DEMAND_TIME_LIABILITIES, SLR_INVESTMENTS) or the key of a head an administrator added.
*/
updateRbiReserveGlRole(
configKey: string,
mappings: { glAccountId: number; weightPercentage: number; liabilityClass: string | null }[]
): Observable<any> {
return this.http.put(`/rbireserveconfig/gl/${configKey}`, { mappings, locale: 'en' });
}
/**
* Adds a head to a return - a reserve, a tax payable, anything the bank's chart carries that the
* built-in roles do not name. `usage` states how the return consumes it: LIABILITY, ASSET_NET,
* MAINTAINED or MEMO.
*/
createRbiReserveRole(role: { label: string; description: string; group: string; usage: string }): Observable<any> {
return this.http.post('/rbireserveconfig/role', { ...role, locale: 'en' });
}
/** Removes an administrator-defined head and the accounts pinned to it. */
deleteRbiReserveRole(roleId: number): Observable<any> {
return this.http.delete(`/rbireserveconfig/role/${roleId}`);
}
/**
* Sets a CRR or SLR rate with effect from a date. Restating an existing effective date edits that
* row; a new date adds one, so past returns keep the rate that was actually in force.
*/
upsertRbiReserveRate(rateType: string, percentage: number, effectiveFrom: string): Observable<any> {
return this.http.post('/rbireserveconfig/rate', {
rateType,
percentage,
effectiveFrom,
locale: 'en',
dateFormat: 'yyyy-MM-dd'
});
}
/** @returns {Observable<any>} */
deleteRbiReserveRate(rateId: number): Observable<any> {
return this.http.delete(`/rbireserveconfig/rate/${rateId}`);
}
}
@@ -0,0 +1,47 @@
/**
* 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 } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiReserveConfigComponent } from './rbi-reserve-config.component';
/**
* GL configuration for OSS 1 — Return on Assets and Liabilities.
*
* Its own screen rather than a section of the reserve configuration: OSS 1 has sixteen roles of its
* own and shares none of them with CRR or SLR, so the two are separate decisions made at separate
* times. The mapping mechanics are identical, so only the scope and the wording differ.
*
* There are no rates here. OSS 1 states a position; it is not a percentage charged on a base.
*/
@Component({
selector: 'mifosx-oss1-gl-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Oss1GlConfigComponent extends RbiReserveConfigComponent {
protected override readonly group = 'OSS1';
protected override readonly showRates = false;
override get screenTitle(): string {
return 'OSS 1 GL Configuration';
}
override get screenSubtitle(): string {
return (
'The GL accounts OSS 1 — Return on Assets and Liabilities reads its figures from. Each head takes the closing ' +
'balance of its accounts as at the quarter end. A head left unmapped reports nothing rather than a nil figure ' +
'that would read as a real position of zero.'
);
}
}
@@ -0,0 +1,46 @@
/**
* 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 } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { RbiReserveConfigComponent } from './rbi-reserve-config.component';
/**
* GL configuration for OSS 2 — Return on Earnings.
*
* Separate from OSS 1 because the two read their accounts differently, not only because they are
* different returns: OSS 1 takes closing balances, OSS 2 takes the movement over the quarter. The
* same account mapped to both would be right on one and wrong on the other, so the screens are kept
* apart rather than sharing a single list of accounts.
*/
@Component({
selector: 'mifosx-oss2-gl-config',
templateUrl: './rbi-reserve-config.component.html',
styleUrls: ['./rbi-reserve-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class Oss2GlConfigComponent extends RbiReserveConfigComponent {
protected override readonly group = 'OSS2';
protected override readonly showRates = false;
override get screenTitle(): string {
return 'OSS 2 GL Configuration';
}
override get screenSubtitle(): string {
return (
'The GL accounts OSS 2 — Return on Earnings reads its figures from. Each item takes the movement over the ' +
'reporting quarter, not a closing balance: an earnings line reports what was earned or spent in the period, so ' +
'the same accounts read as balances would carry forward every earlier quarter as well.'
);
}
}
@@ -0,0 +1,336 @@
<!--
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/.
-->
<div class="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">{{ screenTitle }}</h2>
</div>
<p class="rrc-sub">{{ screenSubtitle }}</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ Rates — only the reserve returns are charged at a percentage ═══ -->
@if (showRates) {
<div class="rrc-card">
<h3 class="rrc-card__title">Reserve rates</h3>
<p class="rrc-card__hint">
Rates are effective-dated rather than overwritten: a return filed for an earlier period keeps reporting the rate
that was actually in force then. Saving against a date that already has a rate edits it; a new date adds one.
</p>
<div class="rrc-rate-now">
@for (type of ['CRR', 'SLR', 'SAVINGS_DEMAND']; track type) {
<div class="rrc-rate-now__item">
<span class="rrc-rate-now__label">
{{ type === 'SAVINGS_DEMAND' ? 'Savings demand share' : type }} in force today
</span>
@if (currentRate(type); as rate) {
<span class="rrc-rate-now__value">{{ rate.percentage }}%</span>
@if (rate.seedDefault) {
<span class="rrc-rate-now__since rrc-rate-now__since--warn">
Placeholder — confirm the rate actually in force
</span>
} @else {
<span class="rrc-rate-now__since">since {{ rate.effectiveFrom }}</span>
}
} @else {
<span class="rrc-rate-now__value rrc-rate-now__value--none">Not set</span>
<span class="rrc-rate-now__since">{{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute</span>
}
</div>
}
</div>
<div class="rrc-rate-form">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__type">
<mat-label>Rate</mat-label>
<mat-select [(ngModel)]="newRate.rateType">
<mat-option value="CRR">CRR — Form 1</mat-option>
<mat-option value="SLR">SLR — Form 9</mat-option>
<!-- Not a reserve ratio, but set by RBI and effective-dated the same way: the share of
savings balances that counts as a demand liability, the rest being time. -->
<mat-option value="SAVINGS_DEMAND">Savings — demand share</mat-option>
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__pct">
<mat-label>Percentage (%)</mat-label>
<input matInput type="number" step="0.0001" min="0" max="100" [(ngModel)]="newRate.percentage" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-rate-form__date">
<mat-label>Effective from</mat-label>
<input matInput type="date" [(ngModel)]="newRate.effectiveFrom" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="savingRate" (click)="saveRate()">
{{ savingRate ? 'Saving…' : 'Save rate' }}
</button>
</div>
<table class="rrc-table">
<thead>
<tr>
<th>Rate</th>
<th>Percentage</th>
<th>Effective from</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
@for (rate of rates; track rate.id) {
<tr>
<td><span class="rrc-key">{{ rate.rateType }}</span></td>
<td class="rrc-num">{{ rate.percentage }}%</td>
<td>{{ rate.effectiveFrom }}</td>
<td>
@if (isFuture(rate)) {
<span class="rrc-badge rrc-badge--future">Future-dated</span>
} @else if (currentRate(rate.rateType)?.id === rate.id) {
<span class="rrc-badge rrc-badge--active">In force</span>
} @else {
<span class="rrc-badge rrc-badge--past">Superseded</span>
}
@if (rate.seedDefault) {
<span class="rrc-badge rrc-badge--seed">Placeholder</span>
}
</td>
<td>
<button mat-button class="rrc-remove" (click)="deleteRate(rate)" title="Remove this rate">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
}
<!-- ═══ GL accounts ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL accounts behind each figure</h3>
<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.
</p>
@if (unmappedCount) {
<div class="rrc-alert rrc-alert--warn">
{{ unmappedCount }} of {{ glRoles.length }} roles have no accounts mapped. Returns that depend on them will
report why they cannot compute instead of showing a figure.
</div>
}
<!--
One tab per return. The roles of four returns on a single list would bury the four that
drive CRR among the thirty that do not, so each return's roles are configured on their own.
Each tab carries its own unmapped count, so a return that cannot compute is visible without
opening its tab.
-->
@if (showTabs) {
<div class="rrc-tabs" role="tablist">
@for (group of roleGroups; track group.name) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="group.name === activeGroup"
[attr.aria-selected]="group.name === activeGroup"
(click)="showGroup(group.name)"
>
{{ group.label }}
@if (unmappedIn(group)) {
<span class="rrc-tab__badge">{{ unmappedIn(group) }} unmapped</span>
} @else {
<span class="rrc-tab__badge rrc-tab__badge--done">all mapped</span>
}
</button>
}
</div>
}
@for (role of visibleRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="isUnmapped(role)">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ roleLabel(role) }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
<span class="rrc-role__usedby">{{ roleUsedBy(role) }}</span>
</div>
@if (isUnmapped(role)) {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
} @else {
<span class="rrc-badge rrc-badge--mapped">
{{ selection[role.configKey].length }} account{{ selection[role.configKey].length === 1 ? '' : 's' }}
</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<!--
One row per mapping rather than a multi-select, because a mapping is no longer just an
account: it carries the portion of the balance this head takes, and which head that
portion falls under. A savings account appears twice here - a demand slice and a time
slice - which a multi-select cannot express.
-->
@if (selection[role.configKey]?.length) {
<table class="rrc-map">
<thead>
<tr>
<th>GL account</th>
<th class="rrc-map__narrow">Portion (%)</th>
@if (role.classified) {
<th class="rrc-map__narrow">Head</th>
}
<th class="rrc-map__actions"></th>
</tr>
</thead>
<tbody>
@for (mapping of selection[role.configKey]; track $index; let i = $index) {
<tr [class.rrc-map__row--invalid]="isRowInvalid(mapping)">
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.glAccountId" [disabled]="savingKey === role.configKey">
@for (o of availableAccounts(role, i); track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
<td>
<!-- The share of this account's balance the head takes. 100 is the whole of it,
which is what every mapping meant before splits existed. -->
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<input
matInput
type="number"
min="0"
max="100"
step="0.0001"
[(ngModel)]="mapping.weightPercentage"
[disabled]="savingKey === role.configKey"
/>
</mat-form-field>
</td>
@if (role.classified) {
<td>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-map__field">
<mat-select [(ngModel)]="mapping.liabilityClass" [disabled]="savingKey === role.configKey">
@for (c of liabilityClasses; track c.value) {
<mat-option [value]="c.value">{{ c.label }}</mat-option>
}
</mat-select>
</mat-form-field>
</td>
}
<td class="rrc-map__actions">
<button mat-button class="rrc-remove" (click)="removeMapping(role, i)" title="Remove this row">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
}
</tbody>
</table>
}
<!--
An account split across rows whose portions do not add to the whole balance is the one
error a split invites and no single row shows: each row looks reasonable while a share of
the balance silently leaves the base.
-->
@for (entry of unbalancedAccounts(role); track entry.glAccountId) {
<div class="rrc-alert rrc-alert--warn rrc-alert--inline">
{{ glLabel(entry.glAccountId) }} is mapped at {{ entry.total }}% in total.
@if (entry.total < 100) {
{{ 100 - entry.total }}% of its balance will not appear on the return.
} @else {
It would be counted {{ entry.total }}% — more than the account holds.
}
</div>
}
<div class="rrc-role__controls">
<button mat-stroked-button (click)="addMapping(role)" [disabled]="savingKey === role.configKey">
<i class="fas fa-plus"></i> Add account
</button>
<button
mat-raised-button
color="primary"
[disabled]="savingKey === role.configKey || !isDirty(role) || hasInvalidRows(role)"
(click)="saveRole(role)"
>
{{ savingKey === role.configKey ? 'Saving…' : 'Save' }}
</button>
@if (!role.builtIn) {
<button mat-button class="rrc-remove" (click)="deleteRole(role)" title="Remove this head">
<i class="fas fa-trash"></i> Remove head
</button>
}
</div>
</div>
}
<!-- ═══ add a head ═══ -->
<div class="rrc-add">
@if (!showAddRole) {
<button mat-stroked-button (click)="showAddRole = true">
<i class="fas fa-plus"></i> Add a head to this return
</button>
<p class="rrc-card__hint">
For anything this bank carries that the heads above do not name — a reserve, a tax payable, bills payable.
The return reads it from the same chart of accounts the balance sheet is built from.
</p>
} @else {
<div class="rrc-add__form">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Name</mat-label>
<input matInput [(ngModel)]="newRole.label" placeholder="e.g. Reserve Fund" />
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>How the return uses it</mat-label>
<mat-select [(ngModel)]="newRole.usage">
@for (u of roleUsages; track u.value) {
<mat-option [value]="u.value">{{ u.label }}</mat-option>
}
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="rrc-add__desc">
<mat-label>Note (optional)</mat-label>
<input matInput [(ngModel)]="newRole.description" placeholder="What this head covers" />
</mat-form-field>
<button mat-raised-button color="primary" [disabled]="addingRole" (click)="addRole()">
{{ addingRole ? 'Adding…' : 'Add head' }}
</button>
<button mat-button (click)="showAddRole = false">Cancel</button>
</div>
}
</div>
</div>
}
</div>
@@ -0,0 +1,475 @@
/**
* 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/.
*/
$primary: #1353a0;
$border: #e2e8f0;
$gray-50: #f8f9fa;
$gray-200: #e8eaed;
$gray-500: #9aa0a6;
$gray-900: #202124;
$green: #137333;
$amber: #b06000;
$red: #c5221f;
.rrc-container {
max-width: 1040px;
margin: 0 auto;
padding: 20px 16px;
}
.rrc-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 6px;
&__title {
font-size: 18px;
font-weight: 600;
color: $gray-900;
margin: 0;
}
}
.rrc-sub {
margin: 0 0 16px;
font-size: 12px;
color: $gray-500;
max-width: 820px;
line-height: 1.6;
}
.rrc-back {
border-radius: 8px !important;
font-size: 12px !important;
font-weight: 600 !important;
height: 32px !important;
border-color: $border !important;
color: $gray-500 !important;
i {
margin-right: 6px;
}
&:hover {
color: $primary !important;
border-color: $primary !important;
}
}
.rrc-alert {
padding: 10px 14px;
border-radius: 8px;
font-size: 12px;
margin-bottom: 14px;
&--error {
background: #fce8e6;
color: $red;
}
&--success {
background: #e6f4ea;
color: $green;
}
&--warn {
background: #fef7e0;
color: $amber;
}
}
.rrc-loading {
padding: 28px;
text-align: center;
color: $gray-500;
font-size: 13px;
}
.rrc-card {
background: #fff;
border: 1px solid $border;
border-radius: 12px;
padding: 18px;
margin-bottom: 18px;
&__title {
margin: 0 0 4px;
font-size: 15px;
font-weight: 600;
color: $gray-900;
}
&__hint {
margin: 0 0 16px;
font-size: 12px;
color: $gray-500;
line-height: 1.6;
}
}
// ── Rates ──
.rrc-rate-now {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-bottom: 18px;
&__item {
flex: 1 1 220px;
background: $gray-50;
border: 1px solid $border;
border-radius: 10px;
padding: 12px 14px;
}
&__label {
display: block;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: $gray-500;
margin-bottom: 4px;
}
&__value {
font-size: 22px;
font-weight: 700;
color: $primary;
&--none {
font-size: 15px;
color: $red;
}
}
&__since {
display: block;
margin-top: 2px;
font-size: 11px;
color: $gray-500;
&--warn {
color: $amber;
font-weight: 600;
}
}
}
.rrc-rate-form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
margin-bottom: 18px;
&__type {
flex: 0 1 190px;
}
&__pct {
flex: 0 1 150px;
}
&__date {
flex: 0 1 190px;
}
}
// ── Tables ──
.rrc-table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
font-weight: 600;
color: $gray-500;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 8px 10px;
border-bottom: 1px solid $border;
}
td {
padding: 9px 10px;
border-bottom: 1px solid $gray-200;
color: $gray-900;
vertical-align: middle;
}
tr:last-child td {
border-bottom: none;
}
}
.rrc-num {
font-variant-numeric: tabular-nums;
font-weight: 600;
}
.rrc-key {
display: inline-block;
font-family: 'Roboto Mono', monospace;
font-size: 10.5px;
background: $gray-50;
border: 1px solid $border;
border-radius: 5px;
padding: 2px 6px;
color: $gray-500;
}
.rrc-badge {
display: inline-block;
font-size: 10.5px;
font-weight: 600;
border-radius: 20px;
padding: 3px 10px;
white-space: nowrap;
&--active,
&--mapped {
background: #e6f4ea;
color: $green;
}
&--future {
background: #e8f0fe;
color: $primary;
}
&--past {
background: $gray-50;
color: $gray-500;
}
&--unmapped {
background: #fce8e6;
color: $red;
}
// Shipped with the installation, never confirmed by this bank.
&--seed {
background: #fef7e0;
color: $amber;
margin-left: 6px;
}
}
// ── GL roles ──
// One tab per return, so a long role list is configured a return at a time. The tabs wrap rather
// than scroll: a return whose tab had scrolled out of sight would look like one that does not exist.
.rrc-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 16px 0 18px;
border-bottom: 1px solid $border;
padding-bottom: 12px;
}
.rrc-tab {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid $border;
border-radius: 8px;
background: #fff;
font: inherit;
font-size: 13px;
font-weight: 600;
color: $gray-900;
cursor: pointer;
&:hover {
background: $gray-50;
}
&--active {
border-color: $primary;
background: rgba(19, 83, 160, 0.06);
color: $primary;
}
&__badge {
font-size: 11px;
font-weight: 500;
padding: 1px 7px;
border-radius: 10px;
background: rgba(176, 96, 0, 0.12);
color: $amber;
// A fully mapped return is worth stating as plainly as an incomplete one, so the tab row can
// be read at a glance instead of by elimination.
&--done {
background: rgba(19, 115, 51, 0.12);
color: $green;
}
}
}
.rrc-role {
border: 1px solid $border;
border-radius: 10px;
padding: 14px 16px;
margin-bottom: 12px;
// An unmapped role stops a return computing, so it reads as a problem, not a blank.
&--unmapped {
border-color: #f5c6c4;
background: #fffafa;
}
&__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 4px;
}
&__name {
font-size: 13.5px;
font-weight: 600;
color: $gray-900;
margin-right: 8px;
}
&__usedby {
margin-left: 8px;
font-size: 11px;
color: $primary;
font-weight: 600;
}
&__desc {
margin: 0 0 12px;
font-size: 12px;
color: $gray-500;
line-height: 1.6;
}
&__controls {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
&__select {
flex: 1 1 420px;
}
&__chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
}
}
.rrc-remove {
min-width: 0 !important;
padding: 0 8px !important;
color: $gray-500 !important;
&:hover {
color: $red !important;
}
}
.rrc-chip {
font-size: 11px;
background: #e8f0fe;
color: $primary;
border-radius: 20px;
padding: 3px 10px;
}
@media (max-width: 720px) {
.rrc-rate-form__type,
.rrc-rate-form__pct,
.rrc-rate-form__date,
.rrc-role__select {
flex: 1 1 100%;
}
}
/* ── mapping rows ──
A mapping is an account, a portion of its balance, and the head that portion falls under, so it
is laid out as a table rather than as chips: the three parts of one row have to read together. */
.rrc-map {
width: 100%;
border-collapse: collapse;
margin-top: 0.75rem;
th {
text-align: left;
font-size: 0.72rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: rgba(0, 0, 0, 0.55);
font-weight: 600;
padding: 0 0.5rem 0.25rem 0;
}
td {
padding: 0.15rem 0.5rem 0.15rem 0;
vertical-align: middle;
}
/* The portion and head columns hold short values; letting them take an equal share of the row
would squeeze the account name, which is the part that identifies the row. */
&__narrow {
width: 8.5rem;
}
&__actions {
width: 3rem;
text-align: right;
}
&__field {
width: 100%;
}
/* An incomplete row is marked where it is, rather than only by the disabled Save button, so it is
clear which row is holding the save back. */
&__row--invalid td {
background: rgba(214, 106, 0, 0.06);
}
}
.rrc-alert--inline {
margin: 0.5rem 0 0;
font-size: 0.82rem;
}
/* ── adding a head ── */
.rrc-add {
margin-top: 1.25rem;
padding-top: 1rem;
border-top: 1px dashed rgba(0, 0, 0, 0.12);
&__form {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
}
&__desc {
flex: 1 1 16rem;
}
}
@@ -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;
}
}
@@ -0,0 +1,216 @@
<!--
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/.
-->
<div class="rrc-container">
<div class="rrc-header">
<button mat-stroked-button [routerLink]="['/organization']" class="rrc-back">
<i class="fas fa-arrow-left"></i> Organization
</button>
<h2 class="rrc-header__title">TDS Configuration</h2>
</div>
<p class="rrc-sub">
Thresholds, rates and statutory references for TDS on deposit interest, per financial year, and the GL account
deducted tax is credited to. The TDS reports, the Form 121 register and each customer's Form 121 tab compute from
these figures only — a year not set up here is a year they refuse to compute.
</p>
@if (errorMsg) {
<div class="rrc-alert rrc-alert--error">{{ errorMsg }}</div>
}
@if (successMsg) {
<div class="rrc-alert rrc-alert--success">{{ successMsg }}</div>
}
@if (loading) {
<div class="rrc-loading">Loading…</div>
} @else {
<!-- ═══ GL ═══ -->
<div class="rrc-card">
<h3 class="rrc-card__title">GL account</h3>
<p class="rrc-card__hint">
Only postable liability accounts are listed. Nothing is preselected: the right account differs per chart of
accounts.
</p>
@for (role of glRoles; track role.configKey) {
<div class="rrc-role" [class.rrc-role--unmapped]="!role.glAccountId">
<div class="rrc-role__head">
<div>
<span class="rrc-role__name">{{ role.label }}</span>
<span class="rrc-key">{{ role.configKey }}</span>
</div>
@if (role.glAccountId) {
<span class="rrc-badge rrc-badge--mapped">{{ role.glCode }} · {{ role.glAccountName }}</span>
} @else {
<span class="rrc-badge rrc-badge--unmapped">Not mapped</span>
}
</div>
<p class="rrc-role__desc">{{ role.description }}</p>
<div class="tdc-gl">
<mat-form-field appearance="outline" subscriptSizing="dynamic" class="tdc-gl__field">
<mat-label>GL account</mat-label>
<mat-select [(ngModel)]="glSelection[role.configKey]" [disabled]="savingGl">
<mat-option [value]="null">— Not mapped —</mat-option>
@for (o of glAccountOptions; track o.id) {
<mat-option [value]="o.id">{{ o.glCode }} · {{ o.name }}</mat-option>
}
</mat-select>
</mat-form-field>
<button
mat-raised-button
color="primary"
[disabled]="savingGl || !isGlDirty(role)"
(click)="saveGl(role)"
>
{{ savingGl ? 'Saving…' : 'Save' }}
</button>
</div>
</div>
}
</div>
<!-- ═══ Financial years ═══ -->
<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.
</p>
<div class="rrc-tabs" role="tablist">
@for (y of years; track y.fy) {
<button
type="button"
role="tab"
class="rrc-tab"
[class.rrc-tab--active]="!isNew && draft?.fy === y.fy"
(click)="select(y)"
>
FY {{ y.fy }}
@if (y.seedDefault) {
<span class="rrc-tab__badge">confirm</span>
}
</button>
}
@if (isNew) {
<button type="button" role="tab" class="rrc-tab rrc-tab--active">New year</button>
} @else {
<button type="button" class="rrc-tab" (click)="startNewYear()">
<i class="fas fa-plus"></i> Add financial year
</button>
}
</div>
@if (draft) {
@if (draft.seedDefault && !isNew) {
<div class="rrc-alert rrc-alert--warn">
These figures were carried over from the values previously built into the application. Check them against
the Finance Act for FY {{ draft.fy }} and save to confirm.
</div>
}
@if (isNew) {
<div class="tdc-grid">
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>Financial year</mat-label>
<input matInput [(ngModel)]="draft.fy" placeholder="2027-28" />
@if (!fyValid) {
<mat-hint>Format: 2027-28</mat-hint>
} @else if (fyTaken) {
<mat-hint>FY {{ draft.fy }} is already configured</mat-hint>
}
</mat-form-field>
</div>
}
<h4 class="tdc-section">Thresholds &amp; rates</h4>
<div class="tdc-grid">
@for (f of rateFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
<input
matInput
type="number"
[min]="0"
[max]="f.kind === 'percent' ? 100 : null"
[step]="f.kind === 'percent' ? 0.0001 : 1"
[(ngModel)]="$any(draft)[f.key]"
/>
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
@if (noPanBelowRate) {
<div class="rrc-alert rrc-alert--warn">The rate without PAN cannot be lower than the rate with PAN.</div>
}
<h4 class="tdc-section">Declaration (Form 121 / 15G / 15H)</h4>
<div class="tdc-grid">
@for (f of declarationFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput type="number" [min]="0" [(ngModel)]="$any(draft)[f.key]" />
}
@if (f.hint) {
<mat-hint>{{ f.hint }}</mat-hint>
}
</mat-form-field>
}
</div>
<h4 class="tdc-section">Statutory references</h4>
<p class="rrc-card__hint">Printed on the reports, certificates and returns for this year.</p>
<div class="tdc-grid">
@for (f of statuteFields; track f.key) {
<mat-form-field appearance="outline" subscriptSizing="dynamic">
<mat-label>{{ f.label }}</mat-label>
@if (f.kind === 'select') {
<mat-select [(ngModel)]="$any(draft)[f.key]">
@for (o of f.options; track o.value) {
<mat-option [value]="o.value">{{ o.label }}</mat-option>
}
</mat-select>
} @else {
<input matInput [(ngModel)]="$any(draft)[f.key]" />
}
</mat-form-field>
}
</div>
<div class="rrc-role__controls">
<button mat-raised-button color="primary" [disabled]="!canSave" (click)="save()">
{{ saving ? 'Saving…' : isNew ? 'Add FY ' + draft.fy : 'Save FY ' + draft.fy }}
</button>
@if (isNew) {
<button mat-button (click)="cancelNew()">Cancel</button>
} @else if (years.length > 1) {
<button mat-button class="rrc-remove" (click)="deleteYear()">
<i class="fas fa-trash"></i> Remove FY {{ draft.fy }}
</button>
}
@if (draft.updatedOn && !isNew) {
<span class="rrc-card__hint">Last saved {{ draft.updatedOn | slice: 0 : 16 }}</span>
}
</div>
} @else {
<div class="rrc-alert rrc-alert--warn">
No financial year is configured. Every TDS screen is unable to compute until one is added.
</div>
}
</div>
}
</div>
@@ -0,0 +1,34 @@
/**
* 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/.
*/
/* Layout on top of the RBI reserve configuration styles this screen shares. */
.tdc-section {
margin: 20px 0 10px;
font-size: 14px;
font-weight: 600;
}
.tdc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 14px 16px;
margin-bottom: 8px;
}
.tdc-gl {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
&__field {
flex: 1 1 320px;
max-width: 520px;
}
}
@@ -0,0 +1,275 @@
/**
* 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 {
TdsConfigResponse,
TdsConfigService,
TdsFyConfigRecord,
TdsGlRole
} from 'app/letter-mgmt/certificates/form-15gh/tds-config.service';
/** One editable field of a financial year, as the form lays it out. */
interface FieldDef {
key: keyof TdsFyConfigRecord;
label: string;
kind: 'amount' | 'percent' | 'int' | 'text' | 'select';
hint?: string;
options?: { value: string; label: string }[];
}
/**
* TDS on deposit interest: the thresholds, rates and statutory references for each financial year,
* and the GL account deducted tax is credited to.
*
* The TDS reports, the Form 121 register and the customer's Form 121 tab all compute from what is
* saved here - nothing about the tax is compiled into the screens. A year missing here is a year
* those screens refuse to compute.
*/
@Component({
selector: 'mifosx-tds-config',
templateUrl: './tds-config.component.html',
styleUrls: ['../rbi-reserve-config/rbi-reserve-config.component.scss', './tds-config.component.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FormsModule
]
})
export class TdsConfigComponent implements OnInit {
private tdsConfig = inject(TdsConfigService);
loading = true;
saving = false;
savingGl = false;
errorMsg = '';
successMsg = '';
years: TdsFyConfigRecord[] = [];
glRoles: TdsGlRole[] = [];
glAccountOptions: { id: number; name: string; glCode: string }[] = [];
/** Year on screen, as a working copy - committed on Save. */
draft: TdsFyConfigRecord | null = null;
/** True while the draft is a year not yet saved. */
isNew = false;
newFy = '';
/** Draft GL selection per role. */
glSelection: Record<string, number | null> = {};
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: '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' },
{ key: 'lateDepositPercentage', label: 'Late deposit interest (% per month)', kind: 'percent' },
{ key: 'roundTo', label: 'Round TDS to nearest (₹)', kind: 'int', hint: '1 = nearest rupee' }
];
readonly declarationFields: FieldDef[] = [
{
key: 'declarationForm',
label: 'Declaration form',
kind: 'select',
options: [
{ value: '121', label: 'Form 121' },
{ value: '15G', label: 'Form 15G / 15H' }
]
},
{ key: 'basicExemption', label: 'Basic exemption limit (₹)', kind: 'amount' },
{ key: 'nilTaxCeiling', label: 'Nil-tax income ceiling (₹)', kind: 'amount' },
{
key: 'declarationStatementFrequency',
label: 'Declaration statement',
kind: 'select',
options: [
{ value: 'MONTHLY', label: 'Monthly' },
{ value: 'QUARTERLY', label: 'Quarterly' }
]
},
{ key: 'declarationStatementDueDay', label: 'Statement due on day', kind: 'int', hint: 'Of the following month / quarter' }
];
readonly statuteFields: FieldDef[] = [
{
key: 'regime',
label: 'Governing Act',
kind: 'select',
options: [
{ value: 'ITA_2025', label: 'Income Tax Act, 2025' },
{ value: 'ITA_1961', label: 'Income-tax Act, 1961' }
]
},
{ key: 'interestTdsSection', label: 'Section charging TDS on interest', kind: 'text' },
{ key: 'thresholdSection', label: 'Threshold section', kind: 'text' },
{ key: 'noPanSection', label: 'No-PAN higher rate section', kind: 'text' },
{ key: 'defaultSection', label: 'Default / late interest section', kind: 'text' },
{ key: 'declarationSection', label: 'Declaration section', kind: 'text' },
{ key: 'declarationRule', label: 'Declaration rule', kind: 'text' },
{ key: 'tdsStatementForm', label: 'Quarterly TDS return', kind: 'text' },
{ key: 'tdsStatementRule', label: 'TDS return rule', kind: 'text' },
{ key: 'certificateForm', label: 'TDS certificate', kind: 'text' }
];
ngOnInit(): void {
this.load();
}
load(): void {
this.loading = true;
this.errorMsg = '';
forkJoin({
config: this.tdsConfig.load().pipe(catchError(() => of(null))),
template: this.tdsConfig.template().pipe(catchError(() => of(null)))
}).subscribe(({ config, template }) => {
this.loading = false;
if (!config) {
this.errorMsg = 'Could not load TDS configuration.';
return;
}
this.glAccountOptions = template?.glAccountOptions || [];
this.apply(config, this.draft?.fy);
});
}
private apply(config: TdsConfigResponse, keepFy?: string): void {
this.years = config.years || [];
this.glRoles = config.glRoles || [];
this.glSelection = {};
for (const role of this.glRoles) {
this.glSelection[role.configKey] = role.glAccountId;
}
const target = this.years.find((y) => y.fy === keepFy) ?? this.years[0] ?? null;
this.select(target);
}
select(year: TdsFyConfigRecord | null): void {
this.draft = year ? { ...year } : null;
this.isNew = false;
}
get unmappedGl(): TdsGlRole[] {
return this.glRoles.filter((r) => !r.glAccountId);
}
/** The year after the latest configured one, e.g. '2027-28' after '2026-27'. */
private nextFy(): string {
const latest = this.years[0]?.fy;
const start = latest ? Number(latest.slice(0, 4)) + 1 : new Date().getFullYear();
return `${start}-${String(start + 1).slice(2)}`;
}
/**
* Starts a new year from the latest one's figures. The law rarely changes all of them at once, so
* copying is the common case; nothing is saved until the administrator reviews and saves it.
*/
startNewYear(): void {
const base = this.years[0];
this.newFy = this.nextFy();
this.draft = base
? { ...base, id: undefined, fy: this.newFy, seedDefault: false, updatedOn: null }
: ({
fy: this.newFy,
regime: 'ITA_2025',
declarationForm: '121',
declarationStatementFrequency: 'MONTHLY',
roundTo: 1
} as TdsFyConfigRecord);
this.isNew = true;
this.successMsg = '';
}
cancelNew(): void {
this.select(this.years[0] ?? null);
}
get fyValid(): boolean {
const m = /^(\d{4})-(\d{2})$/.exec(this.draft?.fy ?? '');
return !!m && (Number(m[1]) + 1) % 100 === Number(m[2]);
}
get fyTaken(): boolean {
return this.isNew && this.years.some((y) => y.fy === this.draft?.fy);
}
/** The no-PAN rate is a penalty rate; below the ordinary rate it would reward a missing PAN. */
get noPanBelowRate(): boolean {
return !!this.draft && Number(this.draft.rateNoPanPercentage) < Number(this.draft.ratePanPercentage);
}
get canSave(): boolean {
return !!this.draft && this.fyValid && !this.fyTaken && !this.noPanBelowRate && !this.saving;
}
save(): void {
if (!this.draft || !this.canSave) {
return;
}
this.saving = true;
this.errorMsg = '';
this.successMsg = '';
const fy = this.draft.fy;
this.tdsConfig.saveYear(this.draft).subscribe({
next: (res) => {
this.saving = false;
this.apply(res, fy);
this.successMsg = `FY ${fy} saved. Every TDS screen now computes with these figures.`;
},
error: (err) => {
this.saving = false;
this.errorMsg = this.messageOf(err, `Could not save FY ${fy}.`);
}
});
}
deleteYear(): void {
if (!this.draft || this.isNew) {
return;
}
const fy = this.draft.fy;
if (!confirm(`Remove the TDS configuration for FY ${fy}? TDS screens will stop computing that year.`)) {
return;
}
this.tdsConfig.deleteYear(fy).subscribe({
next: (res) => {
this.apply(res);
this.successMsg = `FY ${fy} removed.`;
},
error: (err) => (this.errorMsg = this.messageOf(err, `Could not remove FY ${fy}.`))
});
}
saveGl(role: TdsGlRole): void {
this.savingGl = true;
this.errorMsg = '';
this.successMsg = '';
this.tdsConfig.saveGl(role.configKey, this.glSelection[role.configKey] ?? null).subscribe({
next: (res) => {
this.savingGl = false;
this.apply(res, this.draft?.fy);
this.successMsg = `${role.label} GL saved.`;
},
error: (err) => {
this.savingGl = false;
this.errorMsg = this.messageOf(err, `Could not save ${role.label} GL.`);
}
});
}
isGlDirty(role: TdsGlRole): boolean {
return (this.glSelection[role.configKey] ?? null) !== (role.glAccountId ?? null);
}
private messageOf(err: any, fallback: string): string {
return err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || fallback;
}
}