feat: update TDS config, RBI reserve returns, current products, and dormancy reports
This commit is contained in:
@@ -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