feat: update TDS config, RBI reserve returns, current products, and dormancy reports
This commit is contained in:
@@ -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 & 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user