450 lines
14 KiB
TypeScript
450 lines
14 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 { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||
|
||
import { BranchScopeService } from '../core/branch-scope.service';
|
||
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||
import { TdsDeductee, setTdsDeductees, tdsFys } from './tds-reports-data';
|
||
import { buildTdsInterestWorkbook } from './tds-interest-export';
|
||
import { TdsReportsService } from './tds-reports.service';
|
||
import {
|
||
NEAR_THRESHOLD_BAND,
|
||
TdsAccountLine,
|
||
TdsInterestRow,
|
||
TdsInterestSummary,
|
||
TdsReportFilter,
|
||
tdsInterestRows,
|
||
tdsInterestSummary
|
||
} from './tds-reports.compute';
|
||
|
||
/** The three answers the register gives: TDS taken, not taken because of a declaration, none due. */
|
||
type TdsTab = 'paying' | 'declared' | 'not-paying';
|
||
|
||
@Component({
|
||
selector: 'mifosx-tds-interest-report',
|
||
templateUrl: './tds-interest-report.component.html',
|
||
styleUrls: ['./tds-reports.shared.scss'],
|
||
imports: [
|
||
...STANDALONE_SHARED_IMPORTS,
|
||
FaIconComponent,
|
||
FormsModule
|
||
]
|
||
})
|
||
export class TdsInterestReportComponent implements OnInit {
|
||
private scope = inject(BranchScopeService);
|
||
private tdsService = inject(TdsReportsService);
|
||
|
||
/**
|
||
* Office names the loaded customers belong to, for Head Office's branch selector. Read off the
|
||
* data rather than a fixed list, which named branches this bank does not have and left out the
|
||
* ones it does.
|
||
*/
|
||
branches: string[] = [];
|
||
/** Filled by the route resolver from Organization › TDS Configuration. */
|
||
readonly financialYears = tdsFys();
|
||
readonly quarterLabel = QUARTER_LABEL;
|
||
readonly nearBand = NEAR_THRESHOLD_BAND;
|
||
readonly quarters: Quarter[] = [
|
||
1,
|
||
2,
|
||
3,
|
||
4
|
||
];
|
||
|
||
filter: TdsReportFilter = {
|
||
fy: tdsFys()[0] ?? '',
|
||
branchCode: '',
|
||
quarter: 0,
|
||
band: '',
|
||
search: '',
|
||
crossedOnly: false,
|
||
nearThresholdOnly: false
|
||
};
|
||
|
||
/** Which customer's quarter-wise detail is open. */
|
||
expandedId: string | null = null;
|
||
exporting = false;
|
||
selectedTab: TdsTab = 'paying';
|
||
|
||
/** Rows per page; 0 shows the whole list. */
|
||
readonly pageSizeOptions = [
|
||
25,
|
||
50,
|
||
100,
|
||
0
|
||
];
|
||
pageSize = 25;
|
||
pageIndex = 0;
|
||
|
||
/**
|
||
* Row and summary computation is memoised against the filter, because every getter below is
|
||
* read many times per change-detection cycle (the KPI block alone reads `summary` a dozen
|
||
* times) and recomputing the whole book on each read is what made this table slow enough to
|
||
* need paging in the first place.
|
||
*/
|
||
private rowsCacheKey = '';
|
||
private allRows: TdsInterestRow[] = [];
|
||
private paying: TdsInterestRow[] = [];
|
||
private notPaying: TdsInterestRow[] = [];
|
||
private declared: TdsInterestRow[] = [];
|
||
private summaryCacheKey = '';
|
||
private cachedSummary: TdsInterestSummary | null = null;
|
||
|
||
loading = false;
|
||
loadError = '';
|
||
/** The "how to read this page" panel. */
|
||
showHelp = true;
|
||
|
||
readonly accountTypeLabel: Record<TdsAccountLine['type'], string> = {
|
||
FD: 'Fixed deposit',
|
||
RD: 'Recurring deposit',
|
||
SB: 'Savings',
|
||
OTHER: 'Other'
|
||
};
|
||
|
||
ngOnInit(): void {
|
||
if (!this.financialYears.length) {
|
||
this.loadError = 'No financial year is configured for TDS. Set one up under Organization › TDS Configuration.';
|
||
return;
|
||
}
|
||
// A branch login sees only its own book and cannot widen the scope. The platform labels each
|
||
// customer with their office's name, so the login's office name is the filter — the GL branch
|
||
// code list does not know every office and turned a branch login into an empty report.
|
||
if (!this.scope.isHeadOffice) {
|
||
this.filter.branchCode = this.scope.officeName;
|
||
}
|
||
this.loadDeductees();
|
||
}
|
||
|
||
/**
|
||
* Loads the interest the bank actually credited for the selected year.
|
||
*
|
||
* Everything below computes on whatever `setTdsDeductees` holds, so the load is the only place
|
||
* that knows where the figures come from.
|
||
*/
|
||
loadDeductees(): void {
|
||
this.loading = true;
|
||
this.loadError = '';
|
||
// 'FY 2026-27' → 2026, the opening calendar year the platform filters on.
|
||
const openingYear = Number((this.filter.fy.match(/(\d{4})/) || [])[1]) || undefined;
|
||
this.tdsService.getDepositInterest(openingYear).subscribe({
|
||
next: (deductees) => {
|
||
this.loading = false;
|
||
setTdsDeductees(deductees);
|
||
this.branches = this.branchesOf(deductees);
|
||
this.invalidateCaches();
|
||
},
|
||
error: (err: any) => {
|
||
this.loading = false;
|
||
setTdsDeductees([]);
|
||
this.invalidateCaches();
|
||
this.loadError =
|
||
err?.error?.defaultUserMessage || 'Could not load deposit interest. The figures below cannot be shown.';
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Both caches are keyed on the filter alone, so a fresh load must drop them together. Clearing
|
||
* only the summary left the KPIs counting the new data while the tables kept the empty list
|
||
* cached on the first render, before the load returned.
|
||
*/
|
||
private branchesOf(deductees: TdsDeductee[]): string[] {
|
||
const names = new Set(deductees.map((d) => d.branchCode).filter((n) => !!n));
|
||
return [...names].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
private invalidateCaches(): void {
|
||
this.rowsCacheKey = '';
|
||
this.summaryCacheKey = '';
|
||
this.cachedSummary = null;
|
||
}
|
||
|
||
/**
|
||
* A different year needs a different set of interest credits, so the year re-reads from the
|
||
* platform rather than re-filtering the set already in hand.
|
||
*/
|
||
onYearChanged(): void {
|
||
this.onFilterChanged();
|
||
this.loadDeductees();
|
||
}
|
||
|
||
/**
|
||
* Provision and return references for the selected year. The charge moved from
|
||
* section 194A to section 393(1) on 01 April 2026 and the quarterly return
|
||
* became Form 140, so the labels are read from the config rather than written
|
||
* into the template.
|
||
*/
|
||
get statute(): { section: string; statement: string; frequency: string } {
|
||
const cfg = FY_TAX_CONFIG[this.filter.fy];
|
||
return {
|
||
section: cfg?.interestTdsSection ?? '—',
|
||
statement: cfg?.tdsStatementForm ?? '—',
|
||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||
};
|
||
}
|
||
|
||
/** The limits and rates for the selected year, for the help panel. */
|
||
get rules(): { general: number; senior: number; rate: number; rateNoPan: number } | null {
|
||
const cfg = FY_TAX_CONFIG[this.filter.fy];
|
||
if (!cfg) {
|
||
return null;
|
||
}
|
||
return {
|
||
general: cfg.thresholdGeneral,
|
||
senior: cfg.thresholdSenior,
|
||
rate: cfg.rate * 100,
|
||
rateNoPan: cfg.rateNoPan * 100
|
||
};
|
||
}
|
||
|
||
get canSelectBranch(): boolean {
|
||
return this.scope.canSelectBranch;
|
||
}
|
||
|
||
get scopeLabel(): string {
|
||
return this.filter.branchCode ? `${this.filter.branchCode} branch` : 'All branches';
|
||
}
|
||
|
||
/**
|
||
* One plain-language status per customer, so a reader does not have to work it out from the
|
||
* threshold, crossed and saved columns together.
|
||
*/
|
||
statusOf(row: TdsInterestRow): { label: string; tone: 'muted' | 'warn' | 'near' | 'ok' } {
|
||
if (!row.trackedAccounts) {
|
||
return { label: 'Not tracked — no FD/RD', tone: 'muted' };
|
||
}
|
||
if (row.hasDeclaration) {
|
||
const beforeForm = Math.round(row.schedule.totalTds - row.actualTds);
|
||
if (beforeForm > 0) {
|
||
return { label: `Form ${row.form} — ₹${beforeForm.toLocaleString('en-IN')} due from before the form, not yet deducted`, tone: 'warn' };
|
||
}
|
||
if (row.tdsSaved > 0) {
|
||
return { label: `Form ${row.form} — TDS not deducted`, tone: 'ok' };
|
||
}
|
||
return { label: `Form ${row.form} on record — below the limit so far`, tone: 'ok' };
|
||
}
|
||
if (row.schedule.totalTds > 0) {
|
||
const gap = Math.round(row.schedule.totalTds - row.actualTds);
|
||
if (gap > 0) {
|
||
return { label: `Over the limit — ₹${gap.toLocaleString('en-IN')} not yet deducted`, tone: 'warn' };
|
||
}
|
||
if (gap < 0) {
|
||
return { label: `Deducted ₹${(-gap).toLocaleString('en-IN')} more than due — check`, tone: 'warn' };
|
||
}
|
||
return { label: 'Over the limit — TDS deducted', tone: 'ok' };
|
||
}
|
||
if (row.actualTds > 0) {
|
||
return { label: `₹${Math.round(row.actualTds).toLocaleString('en-IN')} deducted but none due — check`, tone: 'warn' };
|
||
}
|
||
if (row.hasDeclaration && row.crossed) {
|
||
return { label: 'Over the limit — 15G/15H, no TDS', tone: 'ok' };
|
||
}
|
||
if (row.nearThreshold) {
|
||
return { label: `Near the limit — ₹${Math.round(row.headroom).toLocaleString('en-IN')} left`, tone: 'near' };
|
||
}
|
||
return { label: 'Below the limit', tone: 'ok' };
|
||
}
|
||
|
||
trackedOf(row: TdsInterestRow): TdsAccountLine[] {
|
||
return row.accounts.filter((a) => a.tracked);
|
||
}
|
||
|
||
untrackedOf(row: TdsInterestRow): TdsAccountLine[] {
|
||
return row.accounts.filter((a) => !a.tracked);
|
||
}
|
||
|
||
/** Recomputes the book only when the filter actually changed. Side-effect free. */
|
||
private ensureRows(): void {
|
||
const key = JSON.stringify(this.filter);
|
||
if (key === this.rowsCacheKey) {
|
||
return;
|
||
}
|
||
this.rowsCacheKey = key;
|
||
this.allRows = tdsInterestRows(this.filter);
|
||
// Three plain answers: whose TDS was taken, whose was not because they gave a declaration, and who
|
||
// owed none. A declarant whose tax was partly taken before the form is shown with the declarants,
|
||
// where both figures sit side by side.
|
||
this.declared = this.allRows.filter((r) => r.hasDeclaration);
|
||
this.paying = this.allRows.filter((r) => !r.hasDeclaration && (r.schedule.totalTds > 0 || r.actualTds > 0));
|
||
this.notPaying = this.allRows.filter((r) => !r.hasDeclaration && r.schedule.totalTds === 0 && r.actualTds === 0);
|
||
}
|
||
|
||
get rows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.allRows;
|
||
}
|
||
|
||
get payingRows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.paying;
|
||
}
|
||
|
||
get notPayingRows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.notPaying;
|
||
}
|
||
|
||
get declaredRows(): TdsInterestRow[] {
|
||
this.ensureRows();
|
||
return this.declared;
|
||
}
|
||
|
||
get summary(): TdsInterestSummary {
|
||
const key = JSON.stringify(this.filter);
|
||
if (key !== this.summaryCacheKey || !this.cachedSummary) {
|
||
this.summaryCacheKey = key;
|
||
this.cachedSummary = tdsInterestSummary(this.filter);
|
||
}
|
||
return this.cachedSummary;
|
||
}
|
||
|
||
/** Every row of the tab on screen, before paging. */
|
||
get visibleRows(): TdsInterestRow[] {
|
||
if (this.selectedTab === 'declared') {
|
||
return this.declaredRows;
|
||
}
|
||
return this.selectedTab === 'paying' ? this.payingRows : this.notPayingRows;
|
||
}
|
||
|
||
get pageCount(): number {
|
||
if (!this.pageSize) {
|
||
return 1;
|
||
}
|
||
return Math.max(1, Math.ceil(this.visibleRows.length / this.pageSize));
|
||
}
|
||
|
||
/**
|
||
* Clamped rather than assigned, so a filter that shrinks the list can never leave the table
|
||
* rendering an out-of-range page, and nothing mutates state during change detection.
|
||
*/
|
||
get currentPage(): number {
|
||
return Math.min(this.pageIndex, this.pageCount - 1);
|
||
}
|
||
|
||
/** The slice actually rendered. */
|
||
get pagedRows(): TdsInterestRow[] {
|
||
const rows = this.visibleRows;
|
||
if (!this.pageSize) {
|
||
return rows;
|
||
}
|
||
const start = this.currentPage * this.pageSize;
|
||
return rows.slice(start, start + this.pageSize);
|
||
}
|
||
|
||
get rangeStart(): number {
|
||
if (!this.visibleRows.length) {
|
||
return 0;
|
||
}
|
||
return this.pageSize ? this.currentPage * this.pageSize + 1 : 1;
|
||
}
|
||
|
||
get rangeEnd(): number {
|
||
const total = this.visibleRows.length;
|
||
return this.pageSize ? Math.min(total, (this.currentPage + 1) * this.pageSize) : total;
|
||
}
|
||
|
||
goToPage(page: number): void {
|
||
this.pageIndex = Math.max(0, Math.min(page, this.pageCount - 1));
|
||
// The open quarter-wise detail belongs to a row that is no longer on screen.
|
||
this.expandedId = null;
|
||
}
|
||
|
||
/** Keeps the first row currently on screen in view when the page size changes. */
|
||
onPageSizeChange(size: number): void {
|
||
const anchor = this.currentPage * (this.pageSize || 1);
|
||
this.pageSize = Number(size);
|
||
this.pageIndex = this.pageSize ? Math.floor(anchor / this.pageSize) : 0;
|
||
this.expandedId = null;
|
||
}
|
||
|
||
/** A different filter is a different list, so paging starts over. */
|
||
onFilterChanged(): void {
|
||
this.pageIndex = 0;
|
||
this.expandedId = null;
|
||
}
|
||
|
||
selectTab(tab: TdsTab): void {
|
||
this.selectedTab = tab;
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
get hasFilters(): boolean {
|
||
return !!(
|
||
this.filter.search ||
|
||
this.filter.quarter ||
|
||
this.filter.band ||
|
||
this.filter.crossedOnly ||
|
||
this.filter.nearThresholdOnly
|
||
);
|
||
}
|
||
|
||
clearFilters(): void {
|
||
this.filter = {
|
||
...this.filter,
|
||
quarter: 0,
|
||
band: '',
|
||
search: '',
|
||
crossedOnly: false,
|
||
nearThresholdOnly: false
|
||
};
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
/** Jump straight to the customers worth chasing a declaration from. */
|
||
showNearThreshold(): void {
|
||
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
|
||
this.onFilterChanged();
|
||
}
|
||
|
||
toggleExpanded(row: TdsInterestRow): void {
|
||
this.expandedId = this.expandedId === row.deductee.customerId ? null : row.deductee.customerId;
|
||
}
|
||
|
||
isExpanded(row: TdsInterestRow): boolean {
|
||
return this.expandedId === row.deductee.customerId;
|
||
}
|
||
|
||
trackRow(_: number, row: TdsInterestRow): string {
|
||
return row.deductee.customerId;
|
||
}
|
||
|
||
async exportWorkbook(): Promise<void> {
|
||
this.exporting = true;
|
||
try {
|
||
const wb = buildTdsInterestWorkbook({
|
||
fy: this.filter.fy,
|
||
scopeLabel: this.scopeLabel,
|
||
rules: this.rules,
|
||
statement: this.statute.statement,
|
||
// What the screen shows under the current filters, every tab included.
|
||
rows: this.rows,
|
||
declaredRows: this.declaredRows,
|
||
summary: this.summary,
|
||
statusOf: (row) => this.statusOf(row)
|
||
});
|
||
const buffer = await wb.xlsx.writeBuffer();
|
||
const blob = new Blob([buffer], {
|
||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
|
||
});
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = `TDS-Register-FY${this.filter.fy}.xlsx`;
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
} finally {
|
||
this.exporting = false;
|
||
}
|
||
}
|
||
}
|