276 lines
9.2 KiB
TypeScript
276 lines
9.2 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|