kyc exception at client creation time and some tds work
This commit is contained in:
@@ -0,0 +1,325 @@
|
||||
<!--
|
||||
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="tdsr">
|
||||
<div class="tdsr__head">
|
||||
<div class="tdsr__head-l">
|
||||
<fa-icon icon="percentage" class="tdsr__head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="tdsr__title">Deposit Interest & TDS</h2>
|
||||
<div class="tdsr__sub">
|
||||
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
|
||||
{{ scopeLabel }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Export' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="tdsr__kpis">
|
||||
<div class="tdsr__kpi">
|
||||
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
|
||||
<div class="tdsr__kpi-l">Deductees</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Interest on FD / RD</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi tdsr__kpi--muted">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">TDS payable</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
|
||||
<div class="tdsr__kpi-l">Crossed the threshold</div>
|
||||
</div>
|
||||
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
|
||||
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
|
||||
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
|
||||
</button>
|
||||
<div class="tdsr__kpi tdsr__kpi--ok">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
|
||||
</div>
|
||||
@if (summary.noPan > 0) {
|
||||
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
|
||||
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Quarter strip -->
|
||||
<div class="tdsr__card">
|
||||
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
|
||||
<div class="tdsr__qstrip">
|
||||
@for (q of summary.byQuarter; track q.quarter) {
|
||||
<div class="tdsr__qcell">
|
||||
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
|
||||
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="tdsr__filters">
|
||||
<label class="tdsr__filter">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="filter.fy">
|
||||
@for (year of financialYears; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
@if (canSelectBranch) {
|
||||
<label class="tdsr__filter">
|
||||
<span>Branch</span>
|
||||
<select [(ngModel)]="filter.branchCode">
|
||||
<option value="">All branches</option>
|
||||
@for (b of branches; track b.code) {
|
||||
<option [value]="b.code">{{ b.name }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
|
||||
<label class="tdsr__filter">
|
||||
<span>Quarter</span>
|
||||
<select [(ngModel)]="filter.quarter">
|
||||
<option [ngValue]="0">Whole year</option>
|
||||
@for (q of quarters; track q) {
|
||||
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="tdsr__filter">
|
||||
<span>Threshold band</span>
|
||||
<select [(ngModel)]="filter.band">
|
||||
<option value="">Both bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="tdsr__filter tdsr__filter--wide">
|
||||
<span>Customer / CIF / PAN</span>
|
||||
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
|
||||
</label>
|
||||
|
||||
<label class="tdsr__check">
|
||||
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
|
||||
<span>Threshold crossed only</span>
|
||||
</label>
|
||||
|
||||
<label class="tdsr__check">
|
||||
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
|
||||
<span>Near threshold only</span>
|
||||
</label>
|
||||
|
||||
@if (hasFilters) {
|
||||
<button type="button" class="tdsr__clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="tdsr__card">
|
||||
<div class="tdsr__table-wrap">
|
||||
<table class="tdsr__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Branch</th>
|
||||
<th>Form · Band</th>
|
||||
<th class="num">Q1</th>
|
||||
<th class="num">Q2</th>
|
||||
<th class="num">Q3</th>
|
||||
<th class="num">Q4</th>
|
||||
<th class="num">Year total</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th>Crossed</th>
|
||||
<th class="num">TDS</th>
|
||||
<th class="num">Saved</th>
|
||||
<th class="col-actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track trackRow($index, row)) {
|
||||
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
|
||||
<td>
|
||||
<div class="tdsr__name">{{ row.deductee.name }}</div>
|
||||
<div class="mono small muted">
|
||||
{{ row.deductee.customerId }} ·
|
||||
@if (row.panOnRecord) {
|
||||
{{ row.deductee.pan }}
|
||||
} @else {
|
||||
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
<td class="small">{{ branchName(row.branchCode) }}</td>
|
||||
<td>
|
||||
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
|
||||
{{ row.form === '121' ? '121' : row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
|
||||
@if (row.hasDeclaration) {
|
||||
<div class="mono small muted">{{ row.declarationUin }}</div>
|
||||
}
|
||||
</td>
|
||||
@for (q of quarters; track q) {
|
||||
<td class="num small">
|
||||
@if (row.quarters[q]) {
|
||||
₹{{ row.quarters[q] | number: '1.0-0' }}
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="num">
|
||||
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
|
||||
@if (row.exemptInterest) {
|
||||
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
|
||||
}
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
|
||||
<td class="small">
|
||||
@if (row.crossedInQuarter) {
|
||||
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
|
||||
} @else if (row.nearThreshold) {
|
||||
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.schedule.totalTds) {
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.tdsSaved) {
|
||||
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="tdsr__row--detail">
|
||||
<td colspan="13">
|
||||
@for (note of row.schedule.notes; track note) {
|
||||
<div class="tdsr__note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@if (row.jointHeldInterest) {
|
||||
<div class="tdsr__note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>
|
||||
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
|
||||
is a joint holder but not the first-named one. Tax there is deducted against the first-named
|
||||
holder, so it is excluded from the figures above.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="tdsr__table tdsr__table--inner">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th class="num">Interest</th>
|
||||
<th class="num">Cumulative</th>
|
||||
<th class="num">Liability to date</th>
|
||||
<th class="num">TDS this quarter</th>
|
||||
<th>Return due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (line of row.schedule.lines; track line.quarter) {
|
||||
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
|
||||
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (line.tds !== 0) {
|
||||
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="tdsr__meta">
|
||||
<span
|
||||
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
|
||||
the deposit product)</span
|
||||
>
|
||||
@if (row.declarationAcceptedOn) {
|
||||
<span
|
||||
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
|
||||
>
|
||||
}
|
||||
@if (row.paidWithoutDeduction) {
|
||||
<span>
|
||||
Paid without deduction:
|
||||
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
|
||||
{{ statute.statement }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (rows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="13" class="tdsr__empty">
|
||||
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 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 * as ExcelJS from 'exceljs';
|
||||
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
|
||||
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TDS_FYS } from './tds-reports-data';
|
||||
import {
|
||||
NEAR_THRESHOLD_BAND,
|
||||
TdsInterestRow,
|
||||
TdsInterestSummary,
|
||||
TdsReportFilter,
|
||||
tdsInterestRows,
|
||||
tdsInterestSummary
|
||||
} from './tds-reports.compute';
|
||||
|
||||
@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);
|
||||
|
||||
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||
readonly financialYears = TDS_FYS;
|
||||
readonly quarterLabel = QUARTER_LABEL;
|
||||
readonly nearBand = NEAR_THRESHOLD_BAND;
|
||||
readonly quarters: Quarter[] = [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
];
|
||||
|
||||
filter: TdsReportFilter = {
|
||||
fy: TDS_FYS[0],
|
||||
branchCode: '',
|
||||
quarter: 0,
|
||||
band: '',
|
||||
search: '',
|
||||
crossedOnly: false,
|
||||
nearThresholdOnly: false
|
||||
};
|
||||
|
||||
/** Which customer's quarter-wise detail is open. */
|
||||
expandedId: string | null = null;
|
||||
exporting = false;
|
||||
|
||||
ngOnInit(): void {
|
||||
// A branch login sees only its own book and cannot widen the scope.
|
||||
const own = this.scope.ownBranchCode;
|
||||
if (own) {
|
||||
this.filter.branchCode = own;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'
|
||||
};
|
||||
}
|
||||
|
||||
get canSelectBranch(): boolean {
|
||||
return this.scope.canSelectBranch;
|
||||
}
|
||||
|
||||
get scopeLabel(): string {
|
||||
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
|
||||
}
|
||||
|
||||
get rows(): TdsInterestRow[] {
|
||||
return tdsInterestRows(this.filter);
|
||||
}
|
||||
|
||||
get summary(): TdsInterestSummary {
|
||||
return tdsInterestSummary(this.filter);
|
||||
}
|
||||
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
/** Jump straight to the customers worth chasing a declaration from. */
|
||||
showNearThreshold(): void {
|
||||
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
branchName(code: string): string {
|
||||
return glBranchName(code);
|
||||
}
|
||||
|
||||
trackRow(_: number, row: TdsInterestRow): string {
|
||||
return row.deductee.customerId;
|
||||
}
|
||||
|
||||
async exportWorkbook(): Promise<void> {
|
||||
this.exporting = true;
|
||||
try {
|
||||
const wb = new ExcelJS.Workbook();
|
||||
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
|
||||
|
||||
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
|
||||
ws.addRow([`Scope: ${this.scopeLabel}`]);
|
||||
ws.addRow([]);
|
||||
ws.addRow([
|
||||
'Customer',
|
||||
'CIF',
|
||||
'PAN',
|
||||
'Branch',
|
||||
'Form',
|
||||
'Band',
|
||||
'Age',
|
||||
'Q1',
|
||||
'Q2',
|
||||
'Q3',
|
||||
'Q4',
|
||||
'Interest (FD/RD)',
|
||||
'Savings interest (exempt)',
|
||||
'Threshold',
|
||||
'Crossed in',
|
||||
'Rate %',
|
||||
'TDS payable',
|
||||
'Declaration',
|
||||
'UIN',
|
||||
'TDS saved',
|
||||
'Paid without deduction'
|
||||
]);
|
||||
|
||||
for (const r of this.rows) {
|
||||
ws.addRow([
|
||||
r.deductee.name,
|
||||
r.deductee.customerId,
|
||||
r.deductee.pan || 'NOT ON RECORD',
|
||||
this.branchName(r.branchCode),
|
||||
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
|
||||
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
|
||||
r.age,
|
||||
r.quarters[1],
|
||||
r.quarters[2],
|
||||
r.quarters[3],
|
||||
r.quarters[4],
|
||||
r.tdsApplicableInterest,
|
||||
r.exemptInterest,
|
||||
r.threshold,
|
||||
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
|
||||
r.rateApplied * 100,
|
||||
r.schedule.totalTds,
|
||||
r.hasDeclaration ? 'Yes' : 'No',
|
||||
r.declarationUin ?? '',
|
||||
r.tdsSaved,
|
||||
r.paidWithoutDeduction
|
||||
]);
|
||||
}
|
||||
|
||||
const s = this.summary;
|
||||
ws.addRow([]);
|
||||
ws.addRow([
|
||||
'TOTAL',
|
||||
`${s.deductees} deductees`,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
s.byQuarter[0].interest,
|
||||
s.byQuarter[1].interest,
|
||||
s.byQuarter[2].interest,
|
||||
s.byQuarter[3].interest,
|
||||
s.applicableInterest,
|
||||
s.exemptInterest,
|
||||
'',
|
||||
'',
|
||||
'',
|
||||
s.totalTds,
|
||||
`${s.declarations} filed`,
|
||||
'',
|
||||
s.tdsSaved,
|
||||
s.paidWithoutDeduction
|
||||
]);
|
||||
|
||||
ws.getRow(1).font = { bold: true, size: 13 };
|
||||
ws.getRow(4).font = { bold: true };
|
||||
ws.columns.forEach((c) => (c.width = 18));
|
||||
|
||||
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-Interest-FY${this.filter.fy}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
<!--
|
||||
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="tdsr">
|
||||
<div class="tdsr__head">
|
||||
<div class="tdsr__head-l">
|
||||
<fa-icon icon="building-columns" class="tdsr__head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="tdsr__title">TDS Remitted to Government</h2>
|
||||
<div class="tdsr__sub">
|
||||
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
|
||||
{{ asAt | date: 'd MMM y' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tdsr__head-r">
|
||||
<label class="tdsr__filter">
|
||||
<span>Financial year</span>
|
||||
<select [(ngModel)]="fy">
|
||||
@for (year of financialYears; track year) {
|
||||
<option [value]="year">FY {{ year }}</option>
|
||||
}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
{{ exporting ? 'Preparing…' : 'Export' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- The control figure, first thing on the page -->
|
||||
@if (reconciled) {
|
||||
<div class="tdsr__banner tdsr__banner--ok">
|
||||
<fa-icon icon="check-circle"></fa-icon>
|
||||
<div>
|
||||
<strong>Reconciled.</strong>
|
||||
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
|
||||
summary.totalDeposited | number: '1.0-0'
|
||||
}}
|
||||
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tdsr__banner tdsr__banner--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
|
||||
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
|
||||
return.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (summary.overdueChallans > 0) {
|
||||
<div class="tdsr__banner tdsr__banner--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
|
||||
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- KPIs -->
|
||||
<div class="tdsr__kpis">
|
||||
<div class="tdsr__kpi tdsr__kpi--warn">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Total TDS deducted</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi tdsr__kpi--ok">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Deposited with Government</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi">
|
||||
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Within due date</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
|
||||
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
|
||||
<div class="tdsr__kpi-l">Interest on late deposit</div>
|
||||
</div>
|
||||
<div class="tdsr__kpi">
|
||||
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
|
||||
<div class="tdsr__kpi-l">Deductees in the return</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Part A: challans -->
|
||||
<div class="tdsr__card">
|
||||
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
|
||||
<div class="tdsr__table-wrap">
|
||||
<table class="tdsr__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Quarter</th>
|
||||
<th>Deduction month</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Due date</th>
|
||||
<th>Deposited on</th>
|
||||
<th>Challan · BSR</th>
|
||||
<th>Status</th>
|
||||
<th class="num">Late-deposit interest</th>
|
||||
<th>{{ statute.statement }} due</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of rows; track trackRow($index, row)) {
|
||||
<tr [class.tdsr__row--warn]="row.late || row.overdue">
|
||||
<td>
|
||||
<strong>{{ quarterLabel[row.quarter] }}</strong>
|
||||
</td>
|
||||
<td class="small">{{ row.month }}</td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
|
||||
<td class="small">
|
||||
@if (row.depositedOn) {
|
||||
{{ row.depositedOn | date: 'd MMM y' }}
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="mono small">
|
||||
@if (row.challanNo) {
|
||||
{{ row.challanNo }}
|
||||
<div class="muted">BSR {{ row.bsrCode }}</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
@if (row.overdue) {
|
||||
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
|
||||
} @else if (row.late) {
|
||||
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
|
||||
} @else if (row.depositedOn) {
|
||||
<span class="tdsr__pill tdsr__pill--ok">On time</span>
|
||||
} @else {
|
||||
<span class="tdsr__pill">Within due date</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.defaultInterest) {
|
||||
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
|
||||
} @else {
|
||||
<span class="muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ row.returnDueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
@if (rows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="9" class="tdsr__empty">
|
||||
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="tdsr__foot">
|
||||
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Part B: return summary -->
|
||||
<div class="tdsr__card">
|
||||
<div class="tdsr__card-head">
|
||||
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
|
||||
</div>
|
||||
<div class="tdsr__ledger">
|
||||
<div class="tdsr__ledger-row">
|
||||
<span>Total interest paid on time deposits</span>
|
||||
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
|
||||
<span>with tax deducted</span>
|
||||
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
|
||||
<span>
|
||||
without deduction — declaration on record
|
||||
<em>mandatory disclosure in {{ statute.statement }}</em>
|
||||
</span>
|
||||
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
|
||||
<span>TDS at standard rate — PAN on record</span>
|
||||
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row">
|
||||
<span>TDS at higher rate — no PAN on record</span>
|
||||
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--total">
|
||||
<span>Total TDS deducted</span>
|
||||
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row">
|
||||
<span>Deposited with Government</span>
|
||||
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row">
|
||||
<span>Still within due date</span>
|
||||
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
|
||||
<span>Difference — must be zero</span>
|
||||
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
|
||||
@if (summary.defaultInterest > 0) {
|
||||
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
|
||||
<span>Interest payable by the bank for late deposit</span>
|
||||
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="tdsr__foot">
|
||||
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
|
||||
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
|
||||
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
|
||||
specification before generating the file.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,203 @@
|
||||
/**
|
||||
* 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, 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 * as ExcelJS from 'exceljs';
|
||||
|
||||
import { BranchScopeService } from '../core/branch-scope.service';
|
||||
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TDS_AS_AT, TDS_FYS } from './tds-reports-data';
|
||||
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-tds-remittance-report',
|
||||
templateUrl: './tds-remittance-report.component.html',
|
||||
styleUrls: ['./tds-reports.shared.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
FaIconComponent,
|
||||
FormsModule
|
||||
]
|
||||
})
|
||||
export class TdsRemittanceReportComponent {
|
||||
private scope = inject(BranchScopeService);
|
||||
|
||||
readonly financialYears = TDS_FYS;
|
||||
readonly quarterLabel = QUARTER_LABEL;
|
||||
readonly asAt = TDS_AS_AT;
|
||||
|
||||
fy = TDS_FYS[0];
|
||||
exporting = false;
|
||||
|
||||
/**
|
||||
* Provision and return references for the selected year. The quarterly return
|
||||
* became Form 140 under the 2025 Act, 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.fy];
|
||||
return {
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
get scopeLabel(): string {
|
||||
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
|
||||
}
|
||||
|
||||
get rows(): TdsRemittanceRow[] {
|
||||
return tdsRemittanceRows(this.fy, this.asAt);
|
||||
}
|
||||
|
||||
get summary(): Tds26qSummary {
|
||||
return tds26qSummary(this.fy, this.asAt);
|
||||
}
|
||||
|
||||
/**
|
||||
* The control that matters. Tax deducted must equal tax deposited plus tax
|
||||
* still within its due date. Anything else means something has gone missing
|
||||
* and must be found before the return is filed.
|
||||
*/
|
||||
get reconciled(): boolean {
|
||||
return this.summary.difference === 0;
|
||||
}
|
||||
|
||||
get totalDefaultInterest(): number {
|
||||
return this.summary.defaultInterest;
|
||||
}
|
||||
|
||||
trackRow(_: number, row: TdsRemittanceRow): string {
|
||||
return `${row.fy}-${row.quarter}`;
|
||||
}
|
||||
|
||||
async exportWorkbook(): Promise<void> {
|
||||
this.exporting = true;
|
||||
try {
|
||||
const s = this.summary;
|
||||
const wb = new ExcelJS.Workbook();
|
||||
|
||||
const challan = wb.addWorksheet('Challans');
|
||||
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
|
||||
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
|
||||
challan.addRow([]);
|
||||
challan.addRow([
|
||||
'Quarter',
|
||||
'Deduction month',
|
||||
'Amount deducted',
|
||||
'Due date',
|
||||
'Deposited on',
|
||||
'Challan no.',
|
||||
'BSR code',
|
||||
'Status',
|
||||
'Delay (months)',
|
||||
'Late-deposit interest',
|
||||
`${this.statute.statement} due`
|
||||
]);
|
||||
for (const r of this.rows) {
|
||||
challan.addRow([
|
||||
`Q${r.quarter}`,
|
||||
r.month,
|
||||
r.amount,
|
||||
r.dueDate,
|
||||
r.depositedOn ?? 'PENDING',
|
||||
r.challanNo ?? '',
|
||||
r.bsrCode ?? '',
|
||||
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
|
||||
r.delayMonths || '',
|
||||
r.defaultInterest || '',
|
||||
r.returnDueDate
|
||||
]);
|
||||
}
|
||||
challan.getRow(1).font = { bold: true, size: 13 };
|
||||
challan.getRow(4).font = { bold: true };
|
||||
challan.columns.forEach((c) => (c.width = 18));
|
||||
|
||||
const recon = wb.addWorksheet('Return Summary');
|
||||
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
|
||||
recon.addRow([]);
|
||||
const lines: [string, number | string][] = [
|
||||
[
|
||||
'Deductees',
|
||||
s.deductees
|
||||
],
|
||||
[
|
||||
'Total interest paid on time deposits',
|
||||
s.totalInterestPaid
|
||||
],
|
||||
[
|
||||
' — with tax deducted',
|
||||
s.interestWithDeduction
|
||||
],
|
||||
[
|
||||
' — without deduction (declaration on record)',
|
||||
s.interestWithoutDeduction
|
||||
],
|
||||
[
|
||||
'TDS at standard rate (PAN on record)',
|
||||
s.tdsAtStandardRate
|
||||
],
|
||||
[
|
||||
'TDS at higher rate (no PAN)',
|
||||
s.tdsAtHigherRate
|
||||
],
|
||||
[
|
||||
'Total TDS deducted',
|
||||
s.totalDeducted
|
||||
],
|
||||
[
|
||||
'Total deposited with Government',
|
||||
s.totalDeposited
|
||||
],
|
||||
[
|
||||
'Still within due date',
|
||||
s.pendingDeposit
|
||||
],
|
||||
[
|
||||
'DIFFERENCE (must be zero)',
|
||||
s.difference
|
||||
],
|
||||
[
|
||||
'Interest payable for late deposit',
|
||||
s.defaultInterest
|
||||
],
|
||||
[
|
||||
'Challans deposited late',
|
||||
s.lateChallans
|
||||
],
|
||||
[
|
||||
'Challans overdue',
|
||||
s.overdueChallans
|
||||
]
|
||||
];
|
||||
for (const line of lines) {
|
||||
recon.addRow(line);
|
||||
}
|
||||
recon.getRow(1).font = { bold: true, size: 13 };
|
||||
recon.columns.forEach((c) => (c.width = 42));
|
||||
recon.getColumn(2).width = 20;
|
||||
|
||||
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-Remittance-FY${this.fy}.xlsx`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
this.exporting = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Deductees and their deposit interest, for the two TDS reports.
|
||||
*
|
||||
* The bank's deduction obligation runs on time deposits, so the seed
|
||||
* gives each customer a mix of fixed and recurring deposits — and some savings
|
||||
* accounts, which are outside the section and must stay out of the aggregate.
|
||||
*
|
||||
* Everything is generated from a fixed seed so the figures do not move between
|
||||
* loads, and the challan schedule is deliberately imperfect: one quarter is
|
||||
* deposited late so the default-interest column has something to show.
|
||||
*/
|
||||
|
||||
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
|
||||
import {
|
||||
Declaration,
|
||||
Form15Kind,
|
||||
InterestCredit,
|
||||
TdsAccountInput
|
||||
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
|
||||
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
|
||||
export const TDS_FYS = [
|
||||
'2026-27',
|
||||
'2025-26'
|
||||
];
|
||||
|
||||
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
|
||||
export const TDS_AS_AT = '2026-08-01';
|
||||
|
||||
export interface TdsDeductee {
|
||||
clientId: number;
|
||||
customerId: string;
|
||||
name: string;
|
||||
pan: string;
|
||||
dob: string;
|
||||
branchCode: string;
|
||||
/** Declarations on record, keyed by financial year. */
|
||||
declarations: Record<string, Declaration>;
|
||||
accounts: TdsAccountInput[];
|
||||
}
|
||||
|
||||
/** One month's remittance of deducted tax to the Government. */
|
||||
export interface TdsChallan {
|
||||
fy: string;
|
||||
/** Quarter the deduction belongs to. */
|
||||
quarter: 1 | 2 | 3 | 4;
|
||||
/** Month the tax was deducted in, e.g. '2026-06'. */
|
||||
month: string;
|
||||
amount: number;
|
||||
/** Statutory date by which it had to be deposited. */
|
||||
dueDate: string;
|
||||
/** When it actually reached the Government — null while still pending. */
|
||||
depositedOn: string | null;
|
||||
challanNo: string | null;
|
||||
bsrCode: string | null;
|
||||
}
|
||||
|
||||
/** Deterministic linear congruential generator — same figures on every load. */
|
||||
function lcg(seed: number): () => number {
|
||||
let s = seed >>> 0;
|
||||
return () => {
|
||||
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
|
||||
return s / 4294967296;
|
||||
};
|
||||
}
|
||||
|
||||
function pick<T>(rand: () => number, list: T[]): T {
|
||||
return list[Math.floor(rand() * list.length)];
|
||||
}
|
||||
|
||||
const FIRST_NAMES = [
|
||||
'Ramesh',
|
||||
'Sunita',
|
||||
'Vitthal',
|
||||
'Kamala',
|
||||
'Ganpat',
|
||||
'Shobha',
|
||||
'Dnyaneshwar',
|
||||
'Mangala',
|
||||
'Bhaskar',
|
||||
'Anita',
|
||||
'Tukaram',
|
||||
'Sarika',
|
||||
'Nivrutti',
|
||||
'Rekha',
|
||||
'Popat',
|
||||
'Chhaya',
|
||||
'Ashok',
|
||||
'Vaishali',
|
||||
'Sitaram',
|
||||
'Jayshree',
|
||||
'Madhukar',
|
||||
'Pushpa',
|
||||
'Eknath',
|
||||
'Sulochana',
|
||||
'Hanumant',
|
||||
'Kalpana'
|
||||
];
|
||||
|
||||
const SURNAMES = [
|
||||
'Jadhav',
|
||||
'Pawar',
|
||||
'Shinde',
|
||||
'Gaikwad',
|
||||
'More',
|
||||
'Kadam',
|
||||
'Bhosale',
|
||||
'Patil',
|
||||
'Sawant',
|
||||
'Chavan',
|
||||
'Salunkhe',
|
||||
'Mane',
|
||||
'Deshmukh',
|
||||
'Thorat',
|
||||
'Nikam',
|
||||
'Wagh'
|
||||
];
|
||||
|
||||
/**
|
||||
* Quarter-end dates for a financial year. Interest is credited on these, which
|
||||
* is what makes the deduction fall due.
|
||||
*/
|
||||
function quarterEnds(fy: string): string[] {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
return [
|
||||
`${y}-06-30`,
|
||||
`${y}-09-30`,
|
||||
`${y}-12-31`,
|
||||
`${y + 1}-03-31`
|
||||
];
|
||||
}
|
||||
|
||||
/** A well-formed individual PAN — fourth character 'P'. */
|
||||
function makePan(rand: () => number, surname: string): string {
|
||||
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
|
||||
const digits = `${1000 + Math.floor(rand() * 9000)}`;
|
||||
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
|
||||
}
|
||||
|
||||
let deducteeCache: TdsDeductee[] | null = null;
|
||||
|
||||
/**
|
||||
* The bank's deductees.
|
||||
*
|
||||
* The spread is deliberate so both reports have every case in them: customers
|
||||
* comfortably under the trigger, customers sitting just below it, customers well
|
||||
* over, senior citizens on the higher threshold, a few with no PAN on record
|
||||
* (deducted at the 206AA rate), some joint deposits held as second holder, and
|
||||
* some declarations that only came in mid-year.
|
||||
*/
|
||||
export function tdsDeductees(): TdsDeductee[] {
|
||||
if (deducteeCache) {
|
||||
return deducteeCache;
|
||||
}
|
||||
const rand = lcg(20260801);
|
||||
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
|
||||
const out: TdsDeductee[] = [];
|
||||
|
||||
for (let i = 0; i < 64; i++) {
|
||||
const branch = branches[i % branches.length];
|
||||
const first = pick(rand, FIRST_NAMES);
|
||||
const last = pick(rand, SURNAMES);
|
||||
const clientId = 5000 + i;
|
||||
|
||||
// A third of the book is senior citizens, which is about right for a
|
||||
// co-operative bank's deposit base.
|
||||
const senior = rand() < 0.34;
|
||||
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
|
||||
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
|
||||
|
||||
// Every seventeenth customer has no PAN on record, so tax comes off at the
|
||||
// higher section 206AA rate. Fixed by index rather than drawn at random, so
|
||||
// the case is always present.
|
||||
const hasPan = i % 17 !== 3;
|
||||
|
||||
// Aim each customer's annual time-deposit interest at a band, so the report
|
||||
// has a proper spread either side of the trigger.
|
||||
const draw = rand();
|
||||
let annualInterest: number;
|
||||
const limit = senior ? 100_000 : 50_000;
|
||||
if (draw < 0.3) {
|
||||
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
|
||||
} else if (draw < 0.45) {
|
||||
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
|
||||
} else if (draw < 0.8) {
|
||||
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
|
||||
} else {
|
||||
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
|
||||
}
|
||||
|
||||
// Push two of the no-PAN customers over the trigger. Left to chance they can
|
||||
// all land under it, and then the 20% line of the return summary comes out
|
||||
// empty — a report line that is never exercised is one nobody has checked.
|
||||
if (!hasPan && (i === 3 || i === 37)) {
|
||||
annualInterest = 70_000 + i * 400;
|
||||
}
|
||||
|
||||
const accounts: TdsAccountInput[] = [];
|
||||
const acctBase = 41_00_000 + i * 137;
|
||||
|
||||
// One or two time deposits, split across the year's quarter ends.
|
||||
const depositCount = rand() < 0.4 ? 2 : 1;
|
||||
for (let d = 0; d < depositCount; d++) {
|
||||
const isRd = rand() < 0.3;
|
||||
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
|
||||
const credits: InterestCredit[] = [];
|
||||
|
||||
for (const fy of TDS_FYS) {
|
||||
// The current year is only part-run, so later quarters have no postings.
|
||||
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
|
||||
if (!ends.length) {
|
||||
continue;
|
||||
}
|
||||
// The prior year ran in full; scale the current year to what has posted.
|
||||
const perQuarter = Math.round((annualInterest * share) / 4);
|
||||
for (const date of ends) {
|
||||
credits.push({ date, amount: perQuarter });
|
||||
}
|
||||
}
|
||||
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
|
||||
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId,
|
||||
interestPostingPeriodType: {
|
||||
// A fifth of the book is on annual posting, so tax on those settles
|
||||
// once at year end rather than quarterly.
|
||||
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
credits
|
||||
});
|
||||
}
|
||||
|
||||
// Most customers also hold a savings account. Its interest must never reach
|
||||
// the deduction aggregate — that is the point of including it here.
|
||||
if (rand() < 0.75) {
|
||||
const sbCredits: InterestCredit[] = [];
|
||||
for (const fy of TDS_FYS) {
|
||||
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
|
||||
}
|
||||
}
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
|
||||
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId,
|
||||
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||
credits: sbCredits
|
||||
});
|
||||
}
|
||||
|
||||
// One in eight is the second holder on somebody else's deposit. Tax there is
|
||||
// deducted against the first-named holder, so it must not land on this
|
||||
// customer's aggregate.
|
||||
if (rand() < 0.12) {
|
||||
const jointCredits: InterestCredit[] = [];
|
||||
for (const fy of TDS_FYS) {
|
||||
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
|
||||
jointCredits.push({ date, amount: 16_000 });
|
||||
}
|
||||
}
|
||||
accounts.push({
|
||||
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
// Somebody else is first-named.
|
||||
primaryClientId: clientId + 1,
|
||||
clientId: clientId + 1,
|
||||
jointHolders: [{ clientId }],
|
||||
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
|
||||
credits: jointCredits
|
||||
});
|
||||
}
|
||||
|
||||
// Declarations. Only worth filing where tax would otherwise be deducted, so
|
||||
// they cluster on the customers over the trigger.
|
||||
const declarations: Record<string, Declaration> = {};
|
||||
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
|
||||
const kind: Form15Kind = senior ? '15H' : '15G';
|
||||
for (const fy of TDS_FYS) {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
// A quarter of them only came in mid-year, which leaves tax already
|
||||
// deducted that the branch cannot reverse.
|
||||
const midYear = rand() < 0.25;
|
||||
declarations[fy] = {
|
||||
kind,
|
||||
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
|
||||
fy,
|
||||
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
out.push({
|
||||
clientId,
|
||||
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
|
||||
name: `${first} ${last}`,
|
||||
pan: hasPan ? makePan(rand, last) : '',
|
||||
dob,
|
||||
branchCode: branch.code,
|
||||
declarations,
|
||||
accounts
|
||||
});
|
||||
}
|
||||
|
||||
deducteeCache = out;
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Statutory date by which tax deducted in a given month must be deposited.
|
||||
*
|
||||
* The seventh of the following month, except for March, which runs to 30 April.
|
||||
*/
|
||||
export function depositDueDate(month: string): string {
|
||||
const [
|
||||
y,
|
||||
m
|
||||
] = month.split('-').map(Number);
|
||||
if (m === 3) {
|
||||
return `${y}-04-30`;
|
||||
}
|
||||
const nextY = m === 12 ? y + 1 : y;
|
||||
const nextM = m === 12 ? 1 : m + 1;
|
||||
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
|
||||
}
|
||||
|
||||
let challanCache: Record<string, TdsChallan[]> | null = null;
|
||||
|
||||
/**
|
||||
* The remittance schedule, derived from what the engine says was deducted.
|
||||
*
|
||||
* Deductions land at each quarter end, so there is one challan per quarter. The
|
||||
* Q2 challan of the prior year is deliberately deposited a month and a bit late
|
||||
* so the late-deposit interest column is exercised, and the current year's latest
|
||||
* quarter is left pending, which is the ordinary state of a live book.
|
||||
*/
|
||||
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
|
||||
if (challanCache) {
|
||||
return challanCache;
|
||||
}
|
||||
const out: Record<string, TdsChallan[]> = {};
|
||||
|
||||
for (const fy of TDS_FYS) {
|
||||
const y = Number(fy.slice(0, 4));
|
||||
const months: Record<number, string> = {
|
||||
1: `${y}-06`,
|
||||
2: `${y}-09`,
|
||||
3: `${y}-12`,
|
||||
4: `${y + 1}-03`
|
||||
};
|
||||
const rows: TdsChallan[] = [];
|
||||
|
||||
for (const q of [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
] as (1 | 2 | 3 | 4)[]) {
|
||||
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
|
||||
if (amount <= 0) {
|
||||
continue;
|
||||
}
|
||||
const month = months[q];
|
||||
const dueDate = depositDueDate(month);
|
||||
// Anything falling after the business date has not been deposited yet.
|
||||
const pending = dueDate > TDS_AS_AT;
|
||||
const late = fy === '2025-26' && q === 2;
|
||||
|
||||
rows.push({
|
||||
fy,
|
||||
quarter: q,
|
||||
month,
|
||||
amount,
|
||||
dueDate,
|
||||
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
|
||||
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
|
||||
bsrCode: pending ? null : '0510308'
|
||||
});
|
||||
}
|
||||
out[fy] = rows;
|
||||
}
|
||||
|
||||
challanCache = out;
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* 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 { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
|
||||
import {
|
||||
NEAR_THRESHOLD_BAND,
|
||||
TdsReportFilter,
|
||||
deductedByQuarter,
|
||||
tds26qSummary,
|
||||
tdsInterestRows,
|
||||
tdsInterestRowsForFy,
|
||||
tdsInterestSummary,
|
||||
tdsRemittanceRows
|
||||
} from './tds-reports.compute';
|
||||
|
||||
/** The complete year in the seed — the one a return would be filed for. */
|
||||
const CLOSED_FY = '2025-26';
|
||||
|
||||
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
|
||||
return {
|
||||
fy: CLOSED_FY,
|
||||
branchCode: '',
|
||||
quarter: 0,
|
||||
band: '',
|
||||
search: '',
|
||||
crossedOnly: false,
|
||||
nearThresholdOnly: false,
|
||||
...overrides
|
||||
};
|
||||
}
|
||||
|
||||
describe('the seed has every case the reports need to show', () => {
|
||||
it('produces deductees on both sides of the threshold', () => {
|
||||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||
expect(rows.length).toBeGreaterThan(20);
|
||||
expect(rows.some((r) => r.crossed)).toBe(true);
|
||||
expect(rows.some((r) => !r.crossed)).toBe(true);
|
||||
expect(rows.some((r) => r.nearThreshold)).toBe(true);
|
||||
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||||
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||||
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
|
||||
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
|
||||
});
|
||||
|
||||
it('gives senior citizens the higher threshold', () => {
|
||||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||
for (const row of rows) {
|
||||
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||||
}
|
||||
});
|
||||
|
||||
it('deducts at the higher rate wherever PAN is missing', () => {
|
||||
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||||
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the reports follow the Act that governed each year', () => {
|
||||
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
|
||||
const old = tdsInterestRowsForFy('2025-26');
|
||||
expect(old.some((r) => r.form === '15G')).toBe(true);
|
||||
expect(old.some((r) => r.form === '15H')).toBe(true);
|
||||
expect(old.some((r) => r.form === '121')).toBe(false);
|
||||
|
||||
// The two forms merged on 01 April 2026.
|
||||
const current = tdsInterestRowsForFy('2026-27');
|
||||
expect(current.every((r) => r.form === '121')).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps the age-based threshold band in both years', () => {
|
||||
for (const fy of TDS_FYS) {
|
||||
const rows = tdsInterestRowsForFy(fy);
|
||||
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
|
||||
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
|
||||
for (const row of rows) {
|
||||
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('savings interest stays out of the 194A aggregate', () => {
|
||||
it('reports it separately and never inside the applicable interest', () => {
|
||||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||
const withSavings = rows.filter((r) => r.exemptInterest > 0);
|
||||
expect(withSavings.length).toBeGreaterThan(0);
|
||||
|
||||
for (const row of withSavings) {
|
||||
// The applicable figure must equal only the time-deposit interest where
|
||||
// this customer is the deductee.
|
||||
const expected = row.deductee.accounts
|
||||
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
|
||||
.flatMap((a) => a.credits)
|
||||
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||||
.reduce((s, c) => s + c.amount, 0);
|
||||
expect(row.tdsApplicableInterest).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the savings totals out of the summary base', () => {
|
||||
const s = tdsInterestSummary(filter());
|
||||
expect(s.exemptInterest).toBeGreaterThan(0);
|
||||
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
|
||||
});
|
||||
});
|
||||
|
||||
describe('joint deposits are attributed to the first-named holder only', () => {
|
||||
it('excludes second-holder interest from the aggregate and shows it apart', () => {
|
||||
const rows = tdsInterestRowsForFy(CLOSED_FY);
|
||||
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
|
||||
expect(secondHolders.length).toBeGreaterThan(0);
|
||||
|
||||
for (const row of secondHolders) {
|
||||
const jointAccounts = row.deductee.accounts.filter(
|
||||
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
|
||||
);
|
||||
expect(jointAccounts.length).toBeGreaterThan(0);
|
||||
// None of that interest may appear in the figure the threshold is tested
|
||||
// against, or one deposit would be taxed against two people.
|
||||
const jointTotal = jointAccounts
|
||||
.flatMap((a) => a.credits)
|
||||
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
|
||||
.reduce((s, c) => s + c.amount, 0);
|
||||
expect(row.jointHeldInterest).toBe(jointTotal);
|
||||
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quarter figures add up to the year', () => {
|
||||
it('per row', () => {
|
||||
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
|
||||
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
|
||||
expect(quarterInterest).toBe(row.tdsApplicableInterest);
|
||||
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
|
||||
}
|
||||
});
|
||||
|
||||
it('and across the summary', () => {
|
||||
const s = tdsInterestSummary(filter());
|
||||
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
|
||||
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('filters', () => {
|
||||
it("narrows to a branch without changing anyone's figures", () => {
|
||||
const all = tdsInterestRows(filter());
|
||||
const branch = all[0].branchCode;
|
||||
const scoped = tdsInterestRows(filter({ branchCode: branch }));
|
||||
expect(scoped.length).toBeGreaterThan(0);
|
||||
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
|
||||
// A branch view must not re-aggregate — the threshold is computed bank-wide.
|
||||
const sample = scoped[0];
|
||||
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
|
||||
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
|
||||
});
|
||||
|
||||
it('finds the customers worth chasing a declaration from', () => {
|
||||
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
|
||||
expect(near.length).toBeGreaterThan(0);
|
||||
for (const row of near) {
|
||||
expect(row.crossed).toBe(false);
|
||||
expect(row.headroom).toBeGreaterThan(0);
|
||||
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
|
||||
}
|
||||
});
|
||||
|
||||
it('narrows to customers who crossed', () => {
|
||||
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
|
||||
expect(crossed.length).toBeGreaterThan(0);
|
||||
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
|
||||
});
|
||||
|
||||
it('searches on name, CIF and PAN', () => {
|
||||
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
|
||||
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
|
||||
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('declarations', () => {
|
||||
it('save tax and record the interest paid gross', () => {
|
||||
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
|
||||
expect(declared.length).toBeGreaterThan(0);
|
||||
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
|
||||
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
|
||||
// be carried even though no tax came off it.
|
||||
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
|
||||
for (const row of declared) {
|
||||
expect(row.declarationUin).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves tax deducted before a mid-year declaration in place', () => {
|
||||
// Some declarations in the seed were accepted in October. Tax already taken
|
||||
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
|
||||
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
|
||||
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
|
||||
);
|
||||
expect(midYear.length).toBeGreaterThan(0);
|
||||
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remittance schedule', () => {
|
||||
it('deposits by the seventh of the following month, and 30 April for March', () => {
|
||||
expect(depositDueDate('2025-06')).toBe('2025-07-07');
|
||||
expect(depositDueDate('2025-12')).toBe('2026-01-07');
|
||||
expect(depositDueDate('2026-03')).toBe('2026-04-30');
|
||||
});
|
||||
|
||||
it('remits exactly what the engine says was deducted', () => {
|
||||
const deducted = deductedByQuarter(CLOSED_FY);
|
||||
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||||
for (const row of rows) {
|
||||
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
|
||||
}
|
||||
});
|
||||
|
||||
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
|
||||
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
|
||||
const late = rows.filter((r) => r.late);
|
||||
expect(late.length).toBeGreaterThan(0);
|
||||
for (const row of late) {
|
||||
expect(row.delayMonths).toBeGreaterThan(0);
|
||||
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
|
||||
}
|
||||
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
|
||||
expect(row.defaultInterest).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('the 26Q reconciliation control', () => {
|
||||
it.each(TDS_FYS)('balances for FY %s', (fy) => {
|
||||
const s = tds26qSummary(fy, TDS_AS_AT);
|
||||
// Deducted must equal deposited plus what is still within its due date.
|
||||
// Anything else means tax has gone missing between the ledger and the
|
||||
// Government, and the return must not be filed until it is found.
|
||||
expect(s.difference).toBe(0);
|
||||
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
|
||||
});
|
||||
|
||||
it('splits the tax between the standard and the no-PAN rate without loss', () => {
|
||||
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||||
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
|
||||
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('splits interest into the deducted and the paid-gross halves', () => {
|
||||
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
|
||||
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
|
||||
// The gross-paid half is the Form 121 disclosure and must not be zero here,
|
||||
// or the report would never exercise that line.
|
||||
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('agrees with the interest report on the same year', () => {
|
||||
const summary = tdsInterestSummary(filter());
|
||||
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the part-run current year', () => {
|
||||
it('has postings only up to the business date', () => {
|
||||
for (const deductee of tdsDeductees()) {
|
||||
for (const account of deductee.accounts) {
|
||||
for (const credit of account.credits) {
|
||||
expect(credit.date <= TDS_AS_AT).toBe(true);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('shows the closed quarter as settled and future obligations as not yet due', () => {
|
||||
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
|
||||
|
||||
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
|
||||
expect(rows.some((r) => r.quarter === 1)).toBe(true);
|
||||
|
||||
// Q3 cannot appear: no interest has posted there and nothing settles there.
|
||||
expect(rows.some((r) => r.quarter === 3)).toBe(false);
|
||||
|
||||
// Q4 *can* appear even in a part-run year — deposits on annual interest
|
||||
// posting settle once, at year end — but that is a future obligation, so it
|
||||
// must read as pending and must never be flagged overdue.
|
||||
for (const row of rows.filter((r) => r.quarter === 4)) {
|
||||
expect(row.depositedOn).toBeNull();
|
||||
expect(row.overdue).toBe(false);
|
||||
expect(row.defaultInterest).toBe(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,395 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Aggregation for the two TDS reports.
|
||||
*
|
||||
* Every rupee figure here comes out of `tds-15gh.compute` — the reports do no
|
||||
* tax arithmetic of their own. Writing it twice is how the register and the
|
||||
* return end up disagreeing, so the engine is the single authority and this
|
||||
* module only groups and totals what it returns.
|
||||
*/
|
||||
|
||||
import {
|
||||
DeclarationForm,
|
||||
FY_TAX_CONFIG,
|
||||
Quarter,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isSeniorInFy,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
lateDeductionInterest,
|
||||
monthsOrPartBetween,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
|
||||
|
||||
// ─────────────────────────────── Filtering ───────────────────────────────
|
||||
|
||||
export interface TdsReportFilter {
|
||||
fy: string;
|
||||
/** '' means every branch. */
|
||||
branchCode: string;
|
||||
/** 0 means the whole year. */
|
||||
quarter: 0 | Quarter;
|
||||
/** '' means both declaration types. */
|
||||
band: '' | 'GENERAL' | 'SENIOR';
|
||||
/** Free text over name, CIF and PAN. */
|
||||
search: string;
|
||||
/** Only customers whose aggregate crossed the deduction trigger. */
|
||||
crossedOnly: boolean;
|
||||
/** Only customers sitting just below the trigger — the follow-up list. */
|
||||
nearThresholdOnly: boolean;
|
||||
}
|
||||
|
||||
/** How close to the trigger counts as "worth chasing a declaration for". */
|
||||
export const NEAR_THRESHOLD_BAND = 6_000;
|
||||
|
||||
// ───────────────────────── Report 1: interest ─────────────────────────
|
||||
|
||||
export interface TdsInterestRow {
|
||||
deductee: TdsDeductee;
|
||||
branchCode: string;
|
||||
/** The form in force for the year — '121' from FY 2026-27. */
|
||||
form: DeclarationForm;
|
||||
/** Which deduction trigger applies. Age-based, even after the forms merged. */
|
||||
band: 'GENERAL' | 'SENIOR';
|
||||
/**
|
||||
* The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27
|
||||
* the two merged, so it duplicates `band` and is kept for pre-2026 years.
|
||||
*/
|
||||
kind: '15G' | '15H';
|
||||
age: number;
|
||||
/** Settlement timing, from the customer's deposit products. */
|
||||
mode: TdsMode;
|
||||
/** Interest on time deposits where this customer is the deductee, by quarter. */
|
||||
quarters: Record<Quarter, number>;
|
||||
/** Total of the above — the figure the deduction trigger is tested against. */
|
||||
tdsApplicableInterest: number;
|
||||
/** Savings interest, shown alongside but outside the charge on time deposits. */
|
||||
exemptInterest: number;
|
||||
/** Interest on deposits where somebody else is the first-named holder. */
|
||||
jointHeldInterest: number;
|
||||
threshold: number;
|
||||
crossed: boolean;
|
||||
/** The quarter the aggregate crossed the trigger, if it did. */
|
||||
crossedInQuarter: Quarter | null;
|
||||
/** How much more interest before the trigger bites. */
|
||||
headroom: number;
|
||||
nearThreshold: boolean;
|
||||
schedule: TdsSchedule;
|
||||
hasDeclaration: boolean;
|
||||
declarationUin: string | null;
|
||||
declarationAcceptedOn: string | null;
|
||||
/** Tax the declaration kept out of the customer's pocket. */
|
||||
tdsSaved: number;
|
||||
/** Interest paid gross on the strength of the declaration — reportable in the return. */
|
||||
paidWithoutDeduction: number;
|
||||
panOnRecord: boolean;
|
||||
rateApplied: number;
|
||||
}
|
||||
|
||||
function emptyQuarters(): Record<Quarter, number> {
|
||||
return { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* One row per deductee for the selected year.
|
||||
*
|
||||
* Memoised on the year alone: building a row runs two schedules, and the getters
|
||||
* that feed the template are read many times per change-detection pass.
|
||||
*/
|
||||
let rowCacheKey = '';
|
||||
let rowCache: TdsInterestRow[] = [];
|
||||
|
||||
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
|
||||
if (rowCacheKey === fy) {
|
||||
return rowCache;
|
||||
}
|
||||
|
||||
const rows: TdsInterestRow[] = [];
|
||||
|
||||
for (const deductee of tdsDeductees()) {
|
||||
const ctx = {
|
||||
fy,
|
||||
dateOfBirth: deductee.dob,
|
||||
pan: deductee.pan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
// Rules 8 to 12 live in the engine: savings interest dropped, joint deposits
|
||||
// attributed to the first-named holder, every branch aggregated.
|
||||
const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy);
|
||||
if (!credits.length && !deductee.accounts.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const declaration = deductee.declarations[fy] ?? null;
|
||||
const mode = overallModeFor(deductee);
|
||||
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||
const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule;
|
||||
|
||||
const quarters = emptyQuarters();
|
||||
for (const line of schedule.lines) {
|
||||
quarters[line.quarter] = line.interest;
|
||||
}
|
||||
|
||||
let exemptInterest = 0;
|
||||
let jointHeldInterest = 0;
|
||||
for (const account of deductee.accounts) {
|
||||
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
|
||||
if (!isTdsApplicable(account)) {
|
||||
exemptInterest += inYear;
|
||||
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
|
||||
jointHeldInterest += inYear;
|
||||
}
|
||||
}
|
||||
|
||||
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
|
||||
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
|
||||
|
||||
rows.push({
|
||||
deductee,
|
||||
branchCode: deductee.branchCode,
|
||||
form: declarationFormFor(fy, deductee.dob),
|
||||
band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL',
|
||||
kind: formKindFor(deductee.dob, fy),
|
||||
age: ageInFy(deductee.dob, fy),
|
||||
mode,
|
||||
quarters,
|
||||
tdsApplicableInterest: schedule.totalInterest,
|
||||
exemptInterest,
|
||||
jointHeldInterest,
|
||||
threshold: schedule.threshold,
|
||||
crossed: schedule.totalInterest > schedule.threshold,
|
||||
crossedInQuarter: crossedLine?.quarter ?? null,
|
||||
headroom,
|
||||
nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND,
|
||||
schedule,
|
||||
hasDeclaration: !!declaration,
|
||||
declarationUin: declaration?.uin ?? null,
|
||||
declarationAcceptedOn: declaration?.acceptedOn ?? null,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
paidWithoutDeduction: schedule.shieldedInterest,
|
||||
panOnRecord: isWellFormedPan(deductee.pan),
|
||||
rateApplied: schedule.rateApplied
|
||||
});
|
||||
}
|
||||
|
||||
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
|
||||
rowCacheKey = fy;
|
||||
rowCache = rows;
|
||||
return rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* When tax settles for a customer whose deposits post interest on different
|
||||
* cycles. A quarterly deposit anywhere means a deduction falls due at the first
|
||||
* quarter end, so that governs.
|
||||
*/
|
||||
function overallModeFor(deductee: TdsDeductee): TdsMode {
|
||||
const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId);
|
||||
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
|
||||
}
|
||||
|
||||
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
|
||||
const needle = filter.search.trim().toLowerCase();
|
||||
return tdsInterestRowsForFy(filter.fy).filter((row) => {
|
||||
if (filter.branchCode && row.branchCode !== filter.branchCode) {
|
||||
return false;
|
||||
}
|
||||
if (filter.band && row.band !== filter.band) {
|
||||
return false;
|
||||
}
|
||||
if (filter.crossedOnly && !row.crossed) {
|
||||
return false;
|
||||
}
|
||||
if (filter.nearThresholdOnly && !row.nearThreshold) {
|
||||
return false;
|
||||
}
|
||||
// A quarter filter narrows to customers who actually had interest in it.
|
||||
if (filter.quarter && !row.quarters[filter.quarter]) {
|
||||
return false;
|
||||
}
|
||||
if (!needle) {
|
||||
return true;
|
||||
}
|
||||
const d = row.deductee;
|
||||
return (
|
||||
d.name.toLowerCase().includes(needle) ||
|
||||
d.customerId.toLowerCase().includes(needle) ||
|
||||
d.pan.toLowerCase().includes(needle)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export interface TdsInterestSummary {
|
||||
deductees: number;
|
||||
/** Interest on time deposits — the base the charge applies to. */
|
||||
applicableInterest: number;
|
||||
/** Savings interest, outside the section. */
|
||||
exemptInterest: number;
|
||||
totalTds: number;
|
||||
crossed: number;
|
||||
nearThreshold: number;
|
||||
declarations: number;
|
||||
tdsSaved: number;
|
||||
paidWithoutDeduction: number;
|
||||
noPan: number;
|
||||
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
|
||||
}
|
||||
|
||||
export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary {
|
||||
const rows = tdsInterestRows(filter);
|
||||
const byQuarter = ([
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4
|
||||
] as Quarter[]).map((quarter) => ({
|
||||
quarter,
|
||||
interest: rows.reduce((s, r) => s + r.quarters[quarter], 0),
|
||||
tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0)
|
||||
}));
|
||||
|
||||
return {
|
||||
deductees: rows.length,
|
||||
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
|
||||
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
crossed: rows.filter((r) => r.crossed).length,
|
||||
nearThreshold: rows.filter((r) => r.nearThreshold).length,
|
||||
declarations: rows.filter((r) => r.hasDeclaration).length,
|
||||
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
|
||||
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||
noPan: rows.filter((r) => !r.panOnRecord).length,
|
||||
byQuarter
|
||||
};
|
||||
}
|
||||
|
||||
// ─────────────────────── Report 2: remittance ───────────────────────
|
||||
|
||||
export interface TdsRemittanceRow extends TdsChallan {
|
||||
/** Statutory due date has passed with nothing deposited. */
|
||||
overdue: boolean;
|
||||
/** Deposited, but after the due date. */
|
||||
late: boolean;
|
||||
/** Months or part months of delay. */
|
||||
delayMonths: number;
|
||||
/** Interest charged for depositing late. */
|
||||
defaultInterest: number;
|
||||
/** Return the quarter's deduction is reported in. */
|
||||
returnDueDate: string;
|
||||
}
|
||||
|
||||
const RETURN_DUE: Record<Quarter, string> = {
|
||||
1: '31 Jul',
|
||||
2: '31 Oct',
|
||||
3: '31 Jan',
|
||||
4: '31 May'
|
||||
};
|
||||
|
||||
/** Tax deducted per quarter, from the engine, for one year. */
|
||||
export function deductedByQuarter(fy: string): Record<number, number> {
|
||||
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
|
||||
for (const row of tdsInterestRowsForFy(fy)) {
|
||||
for (const line of row.schedule.lines) {
|
||||
out[line.quarter] += line.tds;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
|
||||
const deducted: Record<string, Record<number, number>> = {};
|
||||
for (const year of Object.keys(FY_TAX_CONFIG)) {
|
||||
deducted[year] = deductedByQuarter(year);
|
||||
}
|
||||
const challans = tdsChallans(deducted)[fy] ?? [];
|
||||
|
||||
return challans.map((c) => {
|
||||
const overdue = !c.depositedOn && c.dueDate < asAt;
|
||||
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
|
||||
// While a payment is overdue the clock runs to today; once paid it stops on
|
||||
// the payment date.
|
||||
const settledOn = c.depositedOn ?? asAt;
|
||||
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
|
||||
const defaultInterest =
|
||||
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
|
||||
|
||||
return {
|
||||
...c,
|
||||
overdue,
|
||||
late,
|
||||
delayMonths,
|
||||
defaultInterest,
|
||||
returnDueDate: RETURN_DUE[c.quarter]
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The year-end position — what the quarterly TDS return has to reconcile to.
|
||||
*
|
||||
* `difference` is the control figure: tax the engine says was deducted, less what
|
||||
* actually reached the Government. Anything other than zero, once the pending
|
||||
* challans are settled, means something is wrong and must be found before filing.
|
||||
*/
|
||||
export interface Tds26qSummary {
|
||||
fy: string;
|
||||
deductees: number;
|
||||
totalInterestPaid: number;
|
||||
interestWithDeduction: number;
|
||||
/** Paid gross against a declaration — a mandatory disclosure in the return. */
|
||||
interestWithoutDeduction: number;
|
||||
tdsAtStandardRate: number;
|
||||
tdsAtHigherRate: number;
|
||||
totalDeducted: number;
|
||||
totalDeposited: number;
|
||||
pendingDeposit: number;
|
||||
/** Deducted less deposited less still-pending. Must be zero. */
|
||||
difference: number;
|
||||
defaultInterest: number;
|
||||
lateChallans: number;
|
||||
overdueChallans: number;
|
||||
}
|
||||
|
||||
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
|
||||
const rows = tdsInterestRowsForFy(fy);
|
||||
const challans = tdsRemittanceRows(fy, asAt);
|
||||
const standard = rows.filter((r) => r.panOnRecord);
|
||||
const higher = rows.filter((r) => !r.panOnRecord);
|
||||
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
|
||||
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
|
||||
|
||||
return {
|
||||
fy,
|
||||
deductees: rows.length,
|
||||
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
|
||||
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
|
||||
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
|
||||
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
|
||||
totalDeducted,
|
||||
totalDeposited,
|
||||
pendingDeposit,
|
||||
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
|
||||
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
|
||||
lateChallans: challans.filter((c) => c.late).length,
|
||||
overdueChallans: challans.filter((c) => c.overdue).length
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/* Shared by the interest report and the remittance report. */
|
||||
|
||||
.tdsr {
|
||||
--tdsr-line: #e5e7eb;
|
||||
--tdsr-bg: #f9fafb;
|
||||
--tdsr-ink: #111827;
|
||||
--tdsr-muted: #6b7280;
|
||||
--tdsr-blue: #1d4ed8;
|
||||
--tdsr-green: #15803d;
|
||||
--tdsr-amber: #b45309;
|
||||
|
||||
padding: 20px 24px 40px;
|
||||
font-size: 13px;
|
||||
color: var(--tdsr-ink);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
.tdsr__head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.tdsr__head-l {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.tdsr__head-r {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.tdsr__head-ic {
|
||||
font-size: 22px;
|
||||
color: var(--tdsr-blue);
|
||||
margin-top: 3px;
|
||||
}
|
||||
|
||||
.tdsr__title {
|
||||
margin: 0;
|
||||
font-size: 19px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.tdsr__sub {
|
||||
margin-top: 4px;
|
||||
color: var(--tdsr-muted);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tdsr__btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 15px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 7px;
|
||||
background: #fff;
|
||||
font-size: 12.5px;
|
||||
font-weight: 700;
|
||||
color: var(--tdsr-ink);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--tdsr-bg);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Banners ── */
|
||||
.tdsr__banner {
|
||||
display: flex;
|
||||
gap: 11px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
padding: 13px 15px;
|
||||
border-radius: 9px;
|
||||
border-left: 4px solid var(--tdsr-blue);
|
||||
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
|
||||
line-height: 1.55;
|
||||
|
||||
fa-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
color: var(--tdsr-blue);
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--tdsr-green);
|
||||
background: color-mix(in srgb, var(--tdsr-green) 7%, transparent);
|
||||
|
||||
fa-icon {
|
||||
color: var(--tdsr-green);
|
||||
}
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--tdsr-amber);
|
||||
background: color-mix(in srgb, var(--tdsr-amber) 9%, transparent);
|
||||
|
||||
fa-icon {
|
||||
color: var(--tdsr-amber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── KPI strip ── */
|
||||
.tdsr__kpis {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.tdsr__kpi {
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-left: 3px solid var(--tdsr-blue);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
text-align: left;
|
||||
|
||||
&--ok {
|
||||
border-left-color: var(--tdsr-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-left-color: var(--tdsr-amber);
|
||||
}
|
||||
|
||||
&--muted {
|
||||
border-left-color: var(--tdsr-line);
|
||||
}
|
||||
|
||||
/* The near-threshold tile doubles as the button that filters to it. */
|
||||
&--action {
|
||||
border-left-color: var(--tdsr-green);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
|
||||
&:hover {
|
||||
background: var(--tdsr-bg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__kpi-n {
|
||||
font-size: 20px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tdsr__kpi-l {
|
||||
margin-top: 3px;
|
||||
font-size: 11px;
|
||||
color: var(--tdsr-muted);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ── Filters ── */
|
||||
.tdsr__filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 14px;
|
||||
align-items: flex-end;
|
||||
margin-bottom: 16px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 9px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.tdsr__filter {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
|
||||
> span {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
select,
|
||||
input {
|
||||
min-width: 150px;
|
||||
padding: 7px 10px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
font-size: 13px;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
&--wide input {
|
||||
min-width: 230px;
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__check {
|
||||
display: inline-flex;
|
||||
gap: 7px;
|
||||
align-items: center;
|
||||
padding-bottom: 8px;
|
||||
font-size: 12.5px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tdsr__clear {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 4px;
|
||||
padding: 7px 12px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 6px;
|
||||
background: var(--tdsr-bg);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--tdsr-muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ── Cards ── */
|
||||
.tdsr__card {
|
||||
margin-bottom: 18px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 10px;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tdsr__card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 16px;
|
||||
background: var(--tdsr-bg);
|
||||
border-bottom: 1px solid var(--tdsr-line);
|
||||
font-weight: 800;
|
||||
font-size: 12.5px;
|
||||
|
||||
fa-icon {
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__foot {
|
||||
padding: 11px 16px;
|
||||
border-top: 1px solid var(--tdsr-line);
|
||||
background: var(--tdsr-bg);
|
||||
color: var(--tdsr-muted);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.tdsr__empty {
|
||||
padding: 26px 16px;
|
||||
text-align: center;
|
||||
color: var(--tdsr-muted);
|
||||
|
||||
fa-icon {
|
||||
margin-right: 6px;
|
||||
color: var(--tdsr-blue);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Quarter strip ── */
|
||||
.tdsr__qstrip {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
}
|
||||
|
||||
.tdsr__qcell {
|
||||
padding: 14px 16px;
|
||||
border-right: 1px solid var(--tdsr-line);
|
||||
|
||||
&:last-child {
|
||||
border-right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__qcell-l {
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
.tdsr__qcell-n {
|
||||
margin-top: 4px;
|
||||
font-size: 17px;
|
||||
font-weight: 800;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.tdsr__qcell-s {
|
||||
margin-top: 2px;
|
||||
font-size: 11.5px;
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
/* ── Tables ── */
|
||||
.tdsr__table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.tdsr__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 12.5px;
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
padding: 11px 14px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--tdsr-line);
|
||||
color: var(--tdsr-muted);
|
||||
font-weight: 700;
|
||||
font-size: 10.5px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 11px 14px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.small {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
.col-actions {
|
||||
width: 44px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
&--inner {
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__row--warn > td {
|
||||
background: color-mix(in srgb, var(--tdsr-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.tdsr__row--near > td {
|
||||
background: color-mix(in srgb, var(--tdsr-green) 5%, transparent);
|
||||
}
|
||||
|
||||
.tdsr__row--detail > td {
|
||||
background: var(--tdsr-bg);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.tdsr__name {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tdsr__ok {
|
||||
color: var(--tdsr-green);
|
||||
}
|
||||
|
||||
.tdsr__warn-text {
|
||||
color: var(--tdsr-amber);
|
||||
}
|
||||
|
||||
.tdsr__icon-btn {
|
||||
padding: 5px 8px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: var(--tdsr-muted);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--tdsr-bg);
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__note {
|
||||
display: flex;
|
||||
gap: 9px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 11px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border-left: 3px solid var(--tdsr-blue);
|
||||
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
|
||||
fa-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 2px;
|
||||
color: var(--tdsr-blue);
|
||||
}
|
||||
}
|
||||
|
||||
.tdsr__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 18px;
|
||||
font-size: 11.5px;
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
/* ── Ledger (Part B of the remittance report) ── */
|
||||
.tdsr__ledger-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
padding: 11px 16px;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
line-height: 1.5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.num {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
em {
|
||||
display: block;
|
||||
font-size: 10.5px;
|
||||
color: var(--tdsr-amber);
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
|
||||
&--indent {
|
||||
padding-left: 36px;
|
||||
color: var(--tdsr-muted);
|
||||
}
|
||||
|
||||
&--sep {
|
||||
border-top: 1px solid var(--tdsr-line);
|
||||
}
|
||||
|
||||
&--total {
|
||||
border-top: 1px solid var(--tdsr-line);
|
||||
background: var(--tdsr-bg);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
/* The reconciliation control. Green when zero, red when not. */
|
||||
&--control {
|
||||
background: color-mix(in srgb, var(--tdsr-green) 8%, transparent);
|
||||
font-weight: 800;
|
||||
color: var(--tdsr-green);
|
||||
}
|
||||
|
||||
&--bad {
|
||||
background: color-mix(in srgb, var(--tdsr-amber) 10%, transparent);
|
||||
color: var(--tdsr-amber);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Pills ── */
|
||||
.tdsr__pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 9px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--tdsr-line);
|
||||
background: var(--tdsr-bg);
|
||||
color: var(--tdsr-muted);
|
||||
font-size: 10.5px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
|
||||
& + & {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
&--ok {
|
||||
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--tdsr-green) 10%, transparent);
|
||||
color: var(--tdsr-green);
|
||||
}
|
||||
|
||||
&--warn {
|
||||
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
|
||||
background: color-mix(in srgb, var(--tdsr-amber) 12%, transparent);
|
||||
color: var(--tdsr-amber);
|
||||
}
|
||||
|
||||
&--near {
|
||||
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
|
||||
background: color-mix(in srgb, var(--tdsr-blue) 8%, transparent);
|
||||
color: var(--tdsr-blue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* 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/.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The three things the bank actually has to file or hand over.
|
||||
*
|
||||
* 1. The consolidated statement of declarations received — monthly from
|
||||
* FY 2026-27, due on the 7th of the following month, carrying the UIN the
|
||||
* branch allotted to each Form 121.
|
||||
* 2. The deductee annexure of the quarterly return (Form 140), which needs the
|
||||
* UIN against every payee paid gross so the department can tie a
|
||||
* nil-deduction back to a declaration.
|
||||
* 3. The certificate issued to each deductee (Form 131), which is what the
|
||||
* customer uses to claim credit in their own return.
|
||||
*
|
||||
* All figures come from the TDS engine. Nothing here recomputes tax — a return
|
||||
* that disagrees with the register is worse than no return at all.
|
||||
*/
|
||||
|
||||
import {
|
||||
FY_TAX_CONFIG,
|
||||
Quarter,
|
||||
buildUin,
|
||||
declarationStatementDueDate,
|
||||
isWellFormedPan,
|
||||
normaliseFy,
|
||||
quarterEnd,
|
||||
quarterOf
|
||||
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
|
||||
import { TDS_AS_AT } from './tds-reports-data';
|
||||
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
|
||||
|
||||
/** The bank's own deductor particulars, which head every filing. */
|
||||
export interface DeductorParticulars {
|
||||
tan: string;
|
||||
pan: string;
|
||||
name: string;
|
||||
branchAddress: string;
|
||||
responsiblePerson: string;
|
||||
designation: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder particulars for the demo book. In a live install these come from
|
||||
* the organisation's tax registration, not from a constant.
|
||||
*/
|
||||
export const DEDUCTOR: DeductorParticulars = {
|
||||
tan: 'MUMC12345E',
|
||||
pan: 'AAACC1234C',
|
||||
name: 'CBS Nexora Co-operative Bank Ltd.',
|
||||
branchAddress: 'Head Office, Mumbai 400001',
|
||||
responsiblePerson: 'Chief Financial Officer',
|
||||
designation: 'Principal Officer'
|
||||
};
|
||||
|
||||
// ───────────────── 1. Consolidated statement of declarations ─────────────────
|
||||
|
||||
export interface DeclarationStatementLine {
|
||||
/** Running number within the statement. */
|
||||
serial: number;
|
||||
uin: string;
|
||||
payeeName: string;
|
||||
pan: string;
|
||||
customerId: string;
|
||||
branchCode: string;
|
||||
/** Date the branch accepted the declaration. */
|
||||
acceptedOn: string;
|
||||
/**
|
||||
* Income the declaration covers, as estimated when it was accepted. The
|
||||
* statement reports the amount the payer expects to pay gross.
|
||||
*/
|
||||
estimatedIncome: number;
|
||||
/** Interest actually paid without deduction so far. */
|
||||
paidWithoutDeduction: number;
|
||||
/** Whether the declaration would survive scrutiny on the branch's own record. */
|
||||
eligible: boolean;
|
||||
}
|
||||
|
||||
export interface DeclarationStatementPeriod {
|
||||
/** 'MONTHLY' periods are keyed '2026-04'; quarterly ones 'Q1'. */
|
||||
key: string;
|
||||
label: string;
|
||||
/** Statutory upload deadline for this period. */
|
||||
dueDate: string;
|
||||
overdue: boolean;
|
||||
lines: DeclarationStatementLine[];
|
||||
totalPaidWithoutDeduction: number;
|
||||
}
|
||||
|
||||
export interface DeclarationStatement {
|
||||
fy: string;
|
||||
form: string;
|
||||
rule: string;
|
||||
frequency: 'MONTHLY' | 'QUARTERLY';
|
||||
periods: DeclarationStatementPeriod[];
|
||||
totalDeclarations: number;
|
||||
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||
ineligibleCount: number;
|
||||
/** Declarations with no UIN allotted — these cannot be filed at all. */
|
||||
missingUinCount: number;
|
||||
}
|
||||
|
||||
function monthLabel(key: string): string {
|
||||
const months = [
|
||||
'Jan',
|
||||
'Feb',
|
||||
'Mar',
|
||||
'Apr',
|
||||
'May',
|
||||
'Jun',
|
||||
'Jul',
|
||||
'Aug',
|
||||
'Sep',
|
||||
'Oct',
|
||||
'Nov',
|
||||
'Dec'
|
||||
];
|
||||
return `${months[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Groups every declaration on record for the year into the periods the payer has
|
||||
* to upload, and flags the ones that are already past their due date.
|
||||
*
|
||||
* A declaration with no UIN is counted separately rather than filed with a blank:
|
||||
* the portal rejects the line, and a silently dropped row is how a bank ends up
|
||||
* reporting fewer declarations than it holds.
|
||||
*/
|
||||
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
|
||||
const year = normaliseFy(fy);
|
||||
const cfg = FY_TAX_CONFIG[year];
|
||||
if (!cfg) {
|
||||
throw new Error(`No tax configuration for FY ${year}`);
|
||||
}
|
||||
|
||||
const declared = tdsInterestRowsForFy(year).filter((r) => r.hasDeclaration && r.declarationAcceptedOn);
|
||||
const buckets = new Map<string, DeclarationStatementLine[]>();
|
||||
let serial = 0;
|
||||
let ineligibleCount = 0;
|
||||
let missingUinCount = 0;
|
||||
|
||||
for (const row of declared) {
|
||||
const acceptedOn = row.declarationAcceptedOn as string;
|
||||
const key =
|
||||
cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`;
|
||||
|
||||
if (!row.declarationUin) {
|
||||
missingUinCount++;
|
||||
}
|
||||
// The branch cannot compute income earned elsewhere, so eligibility here is
|
||||
// only what the register can see: a valid PAN on an individual.
|
||||
const eligible = isWellFormedPan(row.deductee.pan);
|
||||
if (!eligible) {
|
||||
ineligibleCount++;
|
||||
}
|
||||
|
||||
const line: DeclarationStatementLine = {
|
||||
serial: ++serial,
|
||||
uin: row.declarationUin ?? '',
|
||||
payeeName: row.deductee.name,
|
||||
pan: row.deductee.pan,
|
||||
customerId: row.deductee.customerId,
|
||||
branchCode: row.branchCode,
|
||||
acceptedOn,
|
||||
estimatedIncome: row.tdsApplicableInterest,
|
||||
paidWithoutDeduction: row.paidWithoutDeduction,
|
||||
eligible
|
||||
};
|
||||
|
||||
const list = buckets.get(key);
|
||||
if (list) {
|
||||
list.push(line);
|
||||
} else {
|
||||
buckets.set(key, [line]);
|
||||
}
|
||||
}
|
||||
|
||||
const periods: DeclarationStatementPeriod[] = [...buckets.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([
|
||||
key,
|
||||
lines
|
||||
]) => {
|
||||
const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year);
|
||||
return {
|
||||
key,
|
||||
label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key,
|
||||
dueDate,
|
||||
overdue: dueDate < asAt,
|
||||
lines,
|
||||
totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0)
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
fy: year,
|
||||
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||
rule: cfg.declarationRule,
|
||||
frequency: cfg.declarationStatementFrequency,
|
||||
periods,
|
||||
totalDeclarations: declared.length,
|
||||
ineligibleCount,
|
||||
missingUinCount
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Allots UINs to declarations that do not have one.
|
||||
*
|
||||
* Sequence numbers continue from the highest already in use for the year, so a
|
||||
* re-run does not renumber declarations that have already been filed.
|
||||
*/
|
||||
export function allotMissingUins(rows: TdsInterestRow[], fy: string, tan: string = DEDUCTOR.tan): Map<string, string> {
|
||||
const out = new Map<string, string>();
|
||||
let next = rows.reduce((max, r) => {
|
||||
const seq = r.declarationUin ? Number(r.declarationUin.slice(1, 10)) : 0;
|
||||
return Number.isFinite(seq) && seq > max ? seq : max;
|
||||
}, 0);
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.hasDeclaration && !row.declarationUin) {
|
||||
out.set(row.deductee.customerId, buildUin(++next, fy, tan));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ──────────────────── 2. Deductee annexure of the return ────────────────────
|
||||
|
||||
/** One deductee line in the quarterly return's annexure. */
|
||||
export interface ReturnAnnexureLine {
|
||||
serial: number;
|
||||
payeeName: string;
|
||||
pan: string;
|
||||
/** 'Individual' or 'HUF', from the fourth character of the PAN. */
|
||||
payeeType: string;
|
||||
/** Amount paid or credited in the quarter. */
|
||||
amountPaid: number;
|
||||
/** Tax deducted in the quarter. */
|
||||
taxDeducted: number;
|
||||
rate: number;
|
||||
/**
|
||||
* Reason the payer deducted nothing or less than the standard rate.
|
||||
* Blank where tax was deducted normally.
|
||||
*/
|
||||
remark: string;
|
||||
/** UIN of the declaration, required wherever the remark cites one. */
|
||||
uin: string;
|
||||
branchCode: string;
|
||||
}
|
||||
|
||||
export interface ReturnAnnexure {
|
||||
fy: string;
|
||||
quarter: Quarter;
|
||||
form: string;
|
||||
rule: string;
|
||||
section: string;
|
||||
/** Last day of the quarter the return covers. */
|
||||
periodEnd: string;
|
||||
dueDate: string;
|
||||
lines: ReturnAnnexureLine[];
|
||||
totalPaid: number;
|
||||
totalDeducted: number;
|
||||
/** Lines carrying a nil-deduction remark — these need a UIN. */
|
||||
nilDeductionLines: number;
|
||||
/** Nil-deduction lines with no UIN. The portal will reject these. */
|
||||
missingUinLines: number;
|
||||
}
|
||||
|
||||
const RETURN_DUE_DATE: Record<Quarter, (startYear: number) => string> = {
|
||||
1: (y) => `${y}-07-31`,
|
||||
2: (y) => `${y}-10-31`,
|
||||
3: (y) => `${y + 1}-01-31`,
|
||||
4: (y) => `${y + 1}-05-31`
|
||||
};
|
||||
|
||||
/**
|
||||
* Remark codes for a nil or lower deduction.
|
||||
*
|
||||
* The department's file specification assigns a letter per reason. The letters
|
||||
* have been revised more than once, so they live here as named constants and
|
||||
* must be checked against the current specification before a file is generated —
|
||||
* a wrong code fails validation at upload, or worse, passes and misreports.
|
||||
*/
|
||||
export const NIL_DEDUCTION_REMARK = {
|
||||
/** Payee filed a declaration for receipt without deduction. */
|
||||
DECLARATION: 'B',
|
||||
/** Aggregate stayed within the threshold, so nothing was deductible. */
|
||||
BELOW_THRESHOLD: 'Y',
|
||||
/** Higher rate applied because PAN was not furnished. */
|
||||
NO_PAN: 'C'
|
||||
};
|
||||
|
||||
function payeeTypeOf(pan: string): string {
|
||||
const p = (pan || '').toUpperCase();
|
||||
if (!isWellFormedPan(p)) {
|
||||
return 'Unknown';
|
||||
}
|
||||
return p[3] === 'H' ? 'HUF' : p[3] === 'P' ? 'Individual' : 'Entity';
|
||||
}
|
||||
|
||||
/**
|
||||
* The deductee annexure for one quarter.
|
||||
*
|
||||
* Every payee who was paid interest in the quarter appears, whether or not tax
|
||||
* came off — a payee paid gross against a declaration is precisely what the
|
||||
* department cross-checks, so omitting them is the error to avoid.
|
||||
*/
|
||||
export function returnAnnexure(fy: string, quarter: Quarter): ReturnAnnexure {
|
||||
const year = normaliseFy(fy);
|
||||
const cfg = FY_TAX_CONFIG[year];
|
||||
if (!cfg) {
|
||||
throw new Error(`No tax configuration for FY ${year}`);
|
||||
}
|
||||
const startYear = Number(year.slice(0, 4));
|
||||
const lines: ReturnAnnexureLine[] = [];
|
||||
let serial = 0;
|
||||
|
||||
for (const row of tdsInterestRowsForFy(year)) {
|
||||
const line = row.schedule.lines.find((l) => l.quarter === quarter);
|
||||
const amountPaid = line?.interest ?? 0;
|
||||
const taxDeducted = line?.tds ?? 0;
|
||||
if (amountPaid === 0 && taxDeducted === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The remark explains why the deduction is not simply rate × amount.
|
||||
let remark = '';
|
||||
if (taxDeducted <= 0 && amountPaid > 0) {
|
||||
if (row.hasDeclaration && line?.declarationActive) {
|
||||
remark = NIL_DEDUCTION_REMARK.DECLARATION;
|
||||
} else if (!row.crossed) {
|
||||
remark = NIL_DEDUCTION_REMARK.BELOW_THRESHOLD;
|
||||
}
|
||||
} else if (taxDeducted > 0 && !row.panOnRecord) {
|
||||
remark = NIL_DEDUCTION_REMARK.NO_PAN;
|
||||
}
|
||||
|
||||
lines.push({
|
||||
serial: ++serial,
|
||||
payeeName: row.deductee.name,
|
||||
pan: row.deductee.pan,
|
||||
payeeType: payeeTypeOf(row.deductee.pan),
|
||||
amountPaid,
|
||||
taxDeducted,
|
||||
rate: row.rateApplied,
|
||||
remark,
|
||||
uin: row.declarationUin ?? '',
|
||||
branchCode: row.branchCode
|
||||
});
|
||||
}
|
||||
|
||||
const nilDeduction = lines.filter((l) => l.remark === NIL_DEDUCTION_REMARK.DECLARATION);
|
||||
|
||||
return {
|
||||
fy: year,
|
||||
quarter,
|
||||
form: cfg.tdsStatementForm,
|
||||
rule: cfg.tdsStatementRule,
|
||||
section: cfg.interestTdsSection,
|
||||
periodEnd: quarterEnd(quarter, year),
|
||||
dueDate: RETURN_DUE_DATE[quarter](startYear),
|
||||
lines,
|
||||
totalPaid: lines.reduce((s, l) => s + l.amountPaid, 0),
|
||||
totalDeducted: lines.reduce((s, l) => s + l.taxDeducted, 0),
|
||||
nilDeductionLines: nilDeduction.length,
|
||||
missingUinLines: nilDeduction.filter((l) => !l.uin).length
|
||||
};
|
||||
}
|
||||
|
||||
// ───────────────────── 3. Certificate for the deductee ─────────────────────
|
||||
|
||||
export interface TdsCertificateQuarterLine {
|
||||
quarter: Quarter;
|
||||
label: string;
|
||||
amountPaid: number;
|
||||
taxDeducted: number;
|
||||
/** Tax the bank has actually remitted for this quarter. */
|
||||
taxDeposited: number;
|
||||
}
|
||||
|
||||
export interface TdsCertificate {
|
||||
fy: string;
|
||||
form: string;
|
||||
section: string;
|
||||
/** Certificate number, unique per deductee per year. */
|
||||
certificateNo: string;
|
||||
deductor: DeductorParticulars;
|
||||
payeeName: string;
|
||||
pan: string;
|
||||
customerId: string;
|
||||
branchCode: string;
|
||||
lines: TdsCertificateQuarterLine[];
|
||||
totalPaid: number;
|
||||
totalDeducted: number;
|
||||
/** Interest paid gross against a declaration, disclosed on the certificate. */
|
||||
paidWithoutDeduction: number;
|
||||
declarationUin: string | null;
|
||||
rate: number;
|
||||
/** A certificate cannot be issued without a valid PAN to issue it against. */
|
||||
issuable: boolean;
|
||||
blockingReason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The TDS certificate for one deductee.
|
||||
*
|
||||
* Quarterly deposits are taken as equal to the quarter's deduction: the branch
|
||||
* remits what it deducts, and the remittance report is where any gap between the
|
||||
* two is surfaced. Wiring the certificate to actual challans is the right next
|
||||
* step once challan records come from the backend rather than the seed.
|
||||
*/
|
||||
export function tdsCertificate(row: TdsInterestRow, fy: string): TdsCertificate {
|
||||
const year = normaliseFy(fy);
|
||||
const cfg = FY_TAX_CONFIG[year];
|
||||
if (!cfg) {
|
||||
throw new Error(`No tax configuration for FY ${year}`);
|
||||
}
|
||||
|
||||
const lines: TdsCertificateQuarterLine[] = row.schedule.lines.map((l) => ({
|
||||
quarter: l.quarter,
|
||||
label: l.label,
|
||||
amountPaid: l.interest,
|
||||
taxDeducted: l.tds,
|
||||
taxDeposited: Math.max(0, l.tds)
|
||||
}));
|
||||
|
||||
const issuable = isWellFormedPan(row.deductee.pan);
|
||||
return {
|
||||
fy: year,
|
||||
form: cfg.certificateForm,
|
||||
section: cfg.interestTdsSection,
|
||||
certificateNo: `${DEDUCTOR.tan}/${uinSafe(year)}/${row.deductee.customerId}`,
|
||||
deductor: DEDUCTOR,
|
||||
payeeName: row.deductee.name,
|
||||
pan: row.deductee.pan,
|
||||
customerId: row.deductee.customerId,
|
||||
branchCode: row.branchCode,
|
||||
lines,
|
||||
totalPaid: row.tdsApplicableInterest,
|
||||
totalDeducted: row.schedule.totalTds,
|
||||
paidWithoutDeduction: row.paidWithoutDeduction,
|
||||
declarationUin: row.declarationUin,
|
||||
rate: row.rateApplied,
|
||||
issuable,
|
||||
blockingReason: issuable
|
||||
? null
|
||||
: `No valid PAN on record. A certificate cannot be issued without one — the deductee would be unable to claim credit.`
|
||||
};
|
||||
}
|
||||
|
||||
function uinSafe(fy: string): string {
|
||||
return normaliseFy(fy).replace('-', '');
|
||||
}
|
||||
|
||||
/** Certificates for every deductee who had tax deducted in the year. */
|
||||
export function tdsCertificates(fy: string): TdsCertificate[] {
|
||||
return tdsInterestRowsForFy(normaliseFy(fy))
|
||||
.filter((r) => r.schedule.totalTds > 0 || r.paidWithoutDeduction > 0)
|
||||
.map((r) => tdsCertificate(r, fy));
|
||||
}
|
||||
Reference in New Issue
Block a user