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
@@ -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;
}
}