feat: interest posting

This commit is contained in:
2026-10-05 12:39:14 +05:30
parent 1ffac4b1e5
commit 4fc6784695
10 changed files with 915 additions and 189 deletions
@@ -13,15 +13,21 @@
<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 }}
Interest each customer earned this year, and whether TDS has to be deducted on it ·
<strong>Showing: {{ scopeLabel }}</strong>
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
<div class="tdsr__head-r">
<button type="button" class="tdsr__btn" (click)="showHelp = !showHelp">
<fa-icon icon="question-circle"></fa-icon>
{{ showHelp ? 'Hide help' : 'How to read this page' }}
</button>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
@if (loadError) {
@@ -31,57 +37,87 @@
</div>
}
<!-- Help -->
@if (showHelp) {
<div class="tdsr__help">
<div class="tdsr__help-title"><fa-icon icon="info-circle"></fa-icon> How to read this page</div>
<ol class="tdsr__help-list">
<li>
Every customer with an account is listed. Each account is either
<span class="tdsr__pill tdsr__pill--ok">Tracked</span> — its interest counts toward the TDS limit — or
<span class="tdsr__pill">Not tracked</span>. Open a row (<fa-icon icon="chevron-down"></fa-icon>) to see which
account is which, and why.
</li>
<li>
Only <strong>fixed and recurring deposits</strong> are tracked. Savings interest is shown for information but
never attracts TDS.
</li>
@if (rules) {
<li>
A customer's tracked interest for the whole year, across every branch, is checked against the limit:
<strong>₹{{ rules.general | number: '1.0-0' }}</strong
>, or <strong>₹{{ rules.senior | number: '1.0-0' }}</strong> for a senior citizen (60+).
</li>
<li>
Once over the limit, TDS is <strong>{{ rules.rate | number: '1.0-2' }}%</strong> of the
<em>whole</em> year's interest — <strong>{{ rules.rateNoPan | number: '1.0-2' }}%</strong> if no PAN is on
record.
</li>
}
<li>A customer who has submitted Form 15G/15H is not deducted, even over the limit.</li>
<li>
The TDS figures here are <strong>worked out by this report</strong> from the interest actually posted. They
are not debited from the customer's account automatically.
</li>
</ol>
</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 class="tdsr__kpi-l">Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked</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 class="tdsr__kpi-l">FD / RD interest — counts toward the limit</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 class="tdsr__kpi-l">Savings interest — shown only, no TDS</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 class="tdsr__kpi-l">Customers over the limit</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 class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS to be deducted</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>
<!-- Things worth acting on, only when there are any -->
@if (summary.nearThreshold || summary.declarations || summary.noPan) {
<div class="tdsr__chips">
@if (summary.nearThreshold) {
<button type="button" class="tdsr__chip tdsr__chip--near" (click)="showNearThreshold()">
{{ summary.nearThreshold | number }} near the limit (within ₹{{ nearBand | number: '1.0-0' }}) — ask for
15G/15H
</button>
}
@if (summary.declarations) {
<span class="tdsr__chip tdsr__chip--ok">
{{ summary.declarations | number }} with 15G/15H — ₹{{ summary.tdsSaved | number: '1.0-0' }} not deducted
</span>
}
@if (summary.noPan) {
<span class="tdsr__chip tdsr__chip--warn">
{{ summary.noPan | number }} FD/RD customers without PAN — higher rate applies
</span>
}
</div>
</div>
}
<!-- Filters -->
<div class="tdsr__filters">
@@ -99,8 +135,8 @@
<span>Branch</span>
<select [(ngModel)]="filter.branchCode" (ngModelChange)="onFilterChanged()">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
@for (b of branches; track b) {
<option [value]="b">{{ b }}</option>
}
</select>
</label>
@@ -117,32 +153,32 @@
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<span>Age group</span>
<select [(ngModel)]="filter.band" (ngModelChange)="onFilterChanged()">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="">Everyone</option>
<option value="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>
<span>Search customer</span>
<input
type="text"
[(ngModel)]="filter.search"
(ngModelChange)="onFilterChanged()"
placeholder="name, CIF or PAN"
placeholder="Name, customer ID or PAN"
/>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" (ngModelChange)="onFilterChanged()" />
<span>Threshold crossed only</span>
<span>Over the limit only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" (ngModelChange)="onFilterChanged()" />
<span>Near threshold only</span>
<span>Near the limit only</span>
</label>
@if (hasFilters) {
@@ -150,7 +186,7 @@
}
</div>
<!-- Tabs Selector -->
<!-- Tabs -->
<div class="tdsr__tabs">
<button
type="button"
@@ -158,7 +194,7 @@
[class.tdsr__tab--active]="selectedTab === 'paying'"
(click)="selectTab('paying')"
>
Paying TDS ({{ payingRows.length }})
TDS to deduct ({{ payingRows.length }})
</button>
<button
type="button"
@@ -166,7 +202,7 @@
[class.tdsr__tab--active]="selectedTab === 'not-paying'"
(click)="selectTab('not-paying')"
>
Not Paying TDS ({{ notPayingRows.length }})
No TDS ({{ notPayingRows.length }})
</button>
</div>
@@ -176,87 +212,76 @@
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Customer</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>Accounts</th>
<th class="num">FD / RD interest this year</th>
<th class="num">Limit</th>
<th>Status</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of pagedRows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<tr class="tdsr__row--click" [class.tdsr__row--near]="row.nearThreshold" (click)="toggleExpanded(row)">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
PAN {{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
@if (row.band === 'SENIOR') {
<span class="tdsr__pill">Senior 60+</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 class="small">{{ row.branchCode || '—' }}</td>
<td class="small">
@if (row.trackedAccounts) {
<span class="tdsr__pill tdsr__pill--ok">{{ row.trackedAccounts }} tracked</span>
}
@if (untrackedOf(row).length) {
<span class="tdsr__pill">{{ untrackedOf(row).length }} not tracked</span>
}
</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>
<div class="small muted">+ ₹{{ row.exemptInterest | number: '1.0-0' }} savings (no TDS)</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>
}
<span
class="tdsr__pill"
[class.tdsr__pill--warn]="statusOf(row).tone === 'warn'"
[class.tdsr__pill--near]="statusOf(row).tone === 'near'"
[class.tdsr__pill--ok]="statusOf(row).tone === 'ok'"
>
{{ statusOf(row).label }}
</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 if (row.tdsSaved) {
<span class="small tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }} saved</span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<button
type="button"
class="tdsr__icon-btn"
[attr.aria-label]="isExpanded(row) ? 'Hide details' : 'Show details'"
(click)="$event.stopPropagation(); toggleExpanded(row)"
>
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
@@ -264,89 +289,138 @@
@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>
}
<td colspan="8">
<!-- 1. Which accounts are tracked -->
<div class="tdsr__detail-h">Accounts of {{ row.deductee.name }}</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>
<th>Account no.</th>
<th>Type</th>
<th>Tracked for TDS?</th>
<th class="num">Interest this year</th>
<th class="num">Postings</th>
<th>Last posted</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
@for (a of row.accounts; track a.accountNo) {
<tr>
<td class="mono small">{{ a.accountNo }}</td>
<td class="small">{{ accountTypeLabel[a.type] }}</td>
<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>
@if (a.tracked) {
<span class="tdsr__pill tdsr__pill--ok"><fa-icon icon="check"></fa-icon> Yes</span>
} @else {
<span class="muted">—</span>
<span class="tdsr__pill">No</span>
}
<span class="muted"> {{ a.reason }}</span>
</td>
<td class="num">₹{{ a.interest | number: '1.2-2' }}</td>
<td class="num small">{{ a.postings }}</td>
<td class="small muted">
{{ a.lastPostedOn ? (a.lastPostedOn | date: 'd MMM y') : 'No interest posted yet' }}
</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
>
<!-- 2. How the TDS was worked out -->
@if (row.trackedAccounts) {
<div class="tdsr__detail-h">How the TDS is worked out, quarter by quarter</div>
@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.schedule.lines.length) {
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest in quarter</th>
<th class="num">Total so far</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">limit crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">15G/15H active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">
₹{{ line.cumInterest | number: '1.0-0' }}
<span class="small muted">of ₹{{ row.threshold | number: '1.0-0' }}</span>
</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>
} @else {
<div class="small muted">No interest has been posted on the tracked deposits this year yet.</div>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
<div class="tdsr__meta">
<span>
Paid without deduction because of 15G/15H:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — still reported in
{{ statute.statement }}
</span>
</div>
}
</div>
} @else {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
interest is shown for information only.
</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 is on joint deposits where this
customer is not the first-named holder, so it is counted under that holder instead.
</div>
</div>
}
</td>
</tr>
}
}
@if (visibleRows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No customers in this section match the filters.
<td colspan="8" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon>
@if (loading) {
Loading…
} @else if (selectedTab === 'paying') {
No customer needs TDS deducted for these filters. Check the "No TDS" tab.
} @else {
No customers match these filters.
}
</td>
</tr>
}
@@ -359,8 +433,7 @@
<div class="tdsr__pager-l">
<span>
Showing <strong>{{ rangeStart | number }}–{{ rangeEnd | number }}</strong> of
<strong>{{ visibleRows.length | number }}</strong>
{{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
<strong>{{ visibleRows.length | number }}</strong> customers
</span>
<label class="tdsr__pager-sizel">
Rows