/** * 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 = {}; 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; } }