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