kyc exception at client creation time and some tds work
build-run / Run Lint, Build and Deploy (push) Canceled after 0s
Publish Image in Docker Hub / build (push) Canceled after 0s
Playwright E2E Tests / Run Playwright Tests (push) Canceled after 0s
Tests / testRigor (push) Canceled after 0s

This commit is contained in:
2026-08-05 17:23:56 +05:30
parent cc5cd4c6ab
commit 569f17ef46
37 changed files with 9800 additions and 738 deletions
@@ -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 &amp; 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;
}
}
}
+396
View File
@@ -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);
}
});
});
+395
View File
@@ -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
};
}
+546
View File
@@ -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));
}