kyc exception at client creation time and some tds work
This commit is contained in:
@@ -1069,12 +1069,10 @@ export function buildForm15ghPdf(r: Form15ghRecord, kind: Form15Kind): jsPDF {
|
||||
y = paintPara(
|
||||
c,
|
||||
y,
|
||||
`Declaration under Section ${
|
||||
kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${
|
||||
kind === '15H'
|
||||
? 'applicable to resident individuals aged 60 years or above'
|
||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||
`Declaration under Section ${kind === '15H' ? '197A(1C)' : '197A(1) / 197A(1A)'
|
||||
} of the Income-tax Act, 1961, for non-deduction of tax at source on certain incomes — ${kind === '15H'
|
||||
? 'applicable to resident individuals aged 60 years or above'
|
||||
: 'applicable to resident individuals (other than companies / firms) below 60 years'
|
||||
}.`
|
||||
);
|
||||
|
||||
|
||||
+525
-525
File diff suppressed because it is too large
Load Diff
@@ -505,3 +505,72 @@
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
color: var(--cert-g900);
|
||||
}
|
||||
|
||||
/* Form 121 register — eligibility warnings and the quarter-wise drill-down */
|
||||
|
||||
/* A row the branch needs to look at: an ineligible declaration, or the quarter
|
||||
in which the 194A threshold was crossed. */
|
||||
.cert-row--warn > td {
|
||||
background: color-mix(in srgb, var(--cert-amber) 7%, transparent);
|
||||
}
|
||||
|
||||
.cert-row--detail > td {
|
||||
background: var(--cert-g50);
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.cert-note {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--cert-blue) 6%, transparent);
|
||||
border-left: 3px solid var(--cert-blue);
|
||||
font-size: 12px;
|
||||
color: var(--cert-g900);
|
||||
line-height: 1.5;
|
||||
|
||||
fa-icon {
|
||||
color: var(--cert-blue);
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 6px 0 0;
|
||||
padding-left: 18px;
|
||||
}
|
||||
|
||||
li + li {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&--warn {
|
||||
background: color-mix(in srgb, var(--cert-amber) 8%, transparent);
|
||||
border-left-color: var(--cert-amber);
|
||||
|
||||
fa-icon {
|
||||
color: var(--cert-amber);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The quarter table nested inside an expanded row. */
|
||||
.cert-table--inner {
|
||||
background: #fff;
|
||||
border: 1px solid var(--cert-g200);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 10px;
|
||||
|
||||
th {
|
||||
background: #fff;
|
||||
border-bottom: 1px solid var(--cert-g200);
|
||||
}
|
||||
|
||||
.cert-pill {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,26 +11,60 @@
|
||||
<div class="cert-head-l">
|
||||
<fa-icon icon="file-signature" class="cert-head-ic"></fa-icon>
|
||||
<div>
|
||||
<h2 class="cert-head-t">Form 121</h2>
|
||||
<div class="cert-head-s">TDS-waiver declarations for FD / RD interest income — Form 121.</div>
|
||||
<h2 class="cert-head-t">{{ statute.form }}</h2>
|
||||
<div class="cert-head-s">
|
||||
@if (viewMode === 'demo') {
|
||||
Declarations to stop tax being deducted on fixed and recurring deposit interest under
|
||||
{{ statute.section }}. Reported in {{ statute.statement }}; the consolidated statement of declarations is
|
||||
uploaded {{ statute.frequency }}.
|
||||
} @else {
|
||||
Live customer declarations, sourced from client accounts and KYC profiles.
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-head-r">
|
||||
@if (viewMode === 'demo') {
|
||||
<button
|
||||
type="button"
|
||||
class="cert-cta-btn"
|
||||
(click)="toggleViewMode()"
|
||||
matTooltip="Use real client KYC and account data from Fineract"
|
||||
>
|
||||
<fa-icon icon="database"></fa-icon> Real Declarations
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
type="button"
|
||||
class="cert-cta-btn cert-cta-btn--muted"
|
||||
(click)="toggleViewMode()"
|
||||
matTooltip="Back to the sample data page"
|
||||
>
|
||||
<fa-icon icon="table"></fa-icon> Sample Data
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- KPI strip -->
|
||||
@if (viewMode === 'real') {
|
||||
<mifosx-real-form15gh-view></mifosx-real-form15gh-view>
|
||||
}
|
||||
|
||||
@if (viewMode === 'demo') {
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ count15G() | number }}</div>
|
||||
<div class="cert-kpi-l">Form 121 (under 60)</div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ count15H() | number }}</div>
|
||||
<div class="cert-kpi-l">Form 121 (60+)</div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
@@ -47,6 +81,22 @@
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
@@ -60,11 +110,11 @@
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Form Type</label>
|
||||
<select [(ngModel)]="kindFilter">
|
||||
<option value="">All Form 121</option>
|
||||
<option value="15G">Form 121 (Below 60 years)</option>
|
||||
<option value="15H">Form 121 (Senior Citizen 60+)</option>
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +127,15 @@
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || kindFilter || statusFilter) {
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
@@ -89,54 +147,92 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form Type</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Deposit Principal</th>
|
||||
<th class="num">Est. Interest ({{ records[0]?.fy }})</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (r of filteredRecords; track r.customerId) {
|
||||
<tr>
|
||||
@for (row of filteredRows; track row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ r.customerName }}</div>
|
||||
<div class="mono small muted">{{ r.customerId }} · PAN {{ r.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ r.dob | date: 'd MMM y' }}</div>
|
||||
<div class="cert-cust-name">{{ row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.record.customerId }} · PAN {{ row.record.customerPan }}</div>
|
||||
<div class="small muted">DOB {{ row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="formKind(r) === '15G'"
|
||||
[class.cert-pill--amber]="formKind(r) === '15H'"
|
||||
[class.cert-pill--blue]="row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.band === 'SENIOR'"
|
||||
>
|
||||
Form 121 ({{ formKind(r) }})
|
||||
{{ row.form === '121' ? 'Form 121' : 'Form ' + row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ age(r.dob) }} yrs</td>
|
||||
<td class="small">{{ r.branch }}</td>
|
||||
<td class="num">₹{{ r.depositPrincipal | number: '1.0-0' }}</td>
|
||||
<td class="small">{{ row.age }} yrs</td>
|
||||
<td class="small">{{ row.record.branch }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ r.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
<strong class="cert-interest">₹{{ row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.mode === 'QUARTERLY'">
|
||||
{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (r.status === 'FILED') {
|
||||
@if (row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (r.filedOn) {
|
||||
<div class="mono small muted">{{ r.filedOn | date: 'd MMM y' }}</div>
|
||||
@if (row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(r)"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
@@ -144,7 +240,7 @@
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(r)"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
@@ -152,10 +248,100 @@
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
@if (!row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-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.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">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="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRecords.length === 0) {
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="8" class="cert-empty">
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No declarations match the current filters.
|
||||
</td>
|
||||
@@ -165,4 +351,5 @@
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,31 @@ import {
|
||||
} from '../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm15ghPdf } from '../certificate-pdf';
|
||||
import { DscSignService } from '../../dsc-sign-dialog/dsc-sign.service';
|
||||
import { RealForm15ghViewComponent } from './real-form15gh-view/real-form15gh-view.component';
|
||||
|
||||
export type Form15Kind = '15G' | '15H';
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
Form15Kind,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
isSeniorInFy,
|
||||
normaliseFy,
|
||||
projectQuarterlyCredits,
|
||||
tdsModeFor
|
||||
} from './tds-15gh.compute';
|
||||
|
||||
export type { Form15Kind };
|
||||
export type Form15Status = 'FILED' | 'PENDING';
|
||||
/** Which deduction trigger the payee falls under. Age-based, still. */
|
||||
export type ThresholdBand = 'GENERAL' | 'SENIOR';
|
||||
|
||||
export interface Form15ghRecord {
|
||||
customerId: string;
|
||||
@@ -32,18 +54,46 @@ export interface Form15ghRecord {
|
||||
customerAddress: string;
|
||||
dob: string;
|
||||
branch: string;
|
||||
/** Aggregate interest the bank will pay this FY. */
|
||||
/** Aggregate interest the bank will pay this FY across the customer's time deposits. */
|
||||
estimatedInterest: number;
|
||||
/** Customer's total estimated income for the FY. */
|
||||
/** Customer's total estimated income for the FY, as declared by them. */
|
||||
estimatedIncome: number;
|
||||
/** Sum of FD / RD principals tied to the declaration. */
|
||||
depositPrincipal: number;
|
||||
fy: string;
|
||||
status: Form15Status;
|
||||
filedOn?: string;
|
||||
/** "Yes / No" for: has any income tax been assessed for the customer
|
||||
under the IT Act in the past six years? */
|
||||
/**
|
||||
* "Yes / No" for: has any income tax been assessed for the customer under the
|
||||
* IT Act in the past six years?
|
||||
*/
|
||||
assessed: boolean;
|
||||
/**
|
||||
* The deposit product's interest-posting frequency. Tax falls due when interest
|
||||
* is credited, so this — and not any customer preference — decides whether it
|
||||
* settles quarterly or at year end.
|
||||
*/
|
||||
interestPostingPeriodType: string;
|
||||
}
|
||||
|
||||
/** A register row with everything the engine derived for it, computed once. */
|
||||
export interface Form15ghRow {
|
||||
record: Form15ghRecord;
|
||||
/** The form actually in force for the record's year — '121' from FY 2026-27. */
|
||||
form: DeclarationForm;
|
||||
/**
|
||||
* The 15G/15H label. Only a form distinction up to FY 2025-26; from FY 2026-27
|
||||
* the forms merged and this reflects the threshold band alone.
|
||||
*/
|
||||
kind: Form15Kind;
|
||||
band: ThresholdBand;
|
||||
age: number;
|
||||
mode: TdsMode;
|
||||
/** Schedule as things stand, declaration included where one is on record. */
|
||||
schedule: TdsSchedule;
|
||||
/** Tax the declaration kept out of the customer's pocket this year. */
|
||||
tdsSaved: number;
|
||||
eligibility: EligibilityResult;
|
||||
}
|
||||
|
||||
@Component({
|
||||
@@ -55,7 +105,8 @@ export interface Form15ghRecord {
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
MatTooltip,
|
||||
RealForm15ghViewComponent
|
||||
]
|
||||
})
|
||||
export class Form15ghComponent implements OnInit {
|
||||
@@ -63,40 +114,89 @@ export class Form15ghComponent implements OnInit {
|
||||
private snack = inject(MatSnackBar);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
/** Mode toggle: 'demo' uses mock seed data; 'real' pulls live Fineract data. */
|
||||
viewMode: 'demo' | 'real' = 'demo';
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
/** Which form. Empty = both. */
|
||||
kindFilter: '' | Form15Kind = '';
|
||||
/** Which deduction trigger. Empty = both bands. */
|
||||
bandFilter: '' | ThresholdBand = '';
|
||||
statusFilter: '' | Form15Status = '';
|
||||
/** Show only declarations the branch cannot lawfully accept. */
|
||||
ineligibleOnly = false;
|
||||
|
||||
records: Form15ghRecord[] = [];
|
||||
/** Which row's quarter-wise breakdown is open. */
|
||||
expandedId: string | null = null;
|
||||
|
||||
rows: Form15ghRow[] = [];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.records = seedForm15gh();
|
||||
// Derived once. The template reads these many times per change-detection
|
||||
// pass, and re-running the schedules on every read is what made the other
|
||||
// register screens stall.
|
||||
this.rows = seedForm15gh().map((record) => this.buildRow(record));
|
||||
}
|
||||
|
||||
/** Compute age in years from DoB. */
|
||||
age(dob: string): number {
|
||||
if (!dob) return 0;
|
||||
const today = new Date();
|
||||
const birth = new Date(dob);
|
||||
let age = today.getFullYear() - birth.getFullYear();
|
||||
const m = today.getMonth() - birth.getMonth();
|
||||
if (m < 0 || (m === 0 && today.getDate() < birth.getDate())) age--;
|
||||
return age;
|
||||
private buildRow(record: Form15ghRecord): Form15ghRow {
|
||||
const ctx = {
|
||||
fy: record.fy,
|
||||
dateOfBirth: record.dob,
|
||||
pan: record.customerPan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
const declaration: Declaration | null =
|
||||
record.status === 'FILED'
|
||||
? {
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
// The register holds the filing date; a declaration operates from
|
||||
// the day the branch accepted it.
|
||||
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||
fy: record.fy
|
||||
}
|
||||
: null;
|
||||
|
||||
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||
// The register carries a projected annual figure rather than real postings,
|
||||
// so spread it across the year to drive the schedule.
|
||||
const credits = projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||
|
||||
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
|
||||
const withoutDeclaration = buildSchedule(mode, credits, { ...ctx, declaration: null });
|
||||
|
||||
return {
|
||||
record,
|
||||
form: declarationFormFor(record.fy, record.dob),
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||
age: ageInFy(record.dob, record.fy),
|
||||
mode,
|
||||
schedule,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
eligibility: checkEligibility(ctx, {
|
||||
estimatedInterest: record.estimatedInterest,
|
||||
estimatedIncome: record.estimatedIncome,
|
||||
incomeOnRecord: true
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
/** 15H once 60+, 15G otherwise. */
|
||||
formKind(r: Form15ghRecord): Form15Kind {
|
||||
return this.age(r.dob) >= 60 ? '15H' : '15G';
|
||||
}
|
||||
|
||||
get filteredRecords(): Form15ghRecord[] {
|
||||
get filteredRows(): Form15ghRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.records.filter((r) => {
|
||||
if (this.kindFilter && this.formKind(r) !== this.kindFilter) return false;
|
||||
if (this.statusFilter && r.status !== this.statusFilter) return false;
|
||||
if (!q) return true;
|
||||
return this.rows.filter((row) => {
|
||||
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
const r = row.record;
|
||||
return (
|
||||
r.customerName.toLowerCase().includes(q) ||
|
||||
r.customerId.toLowerCase().includes(q) ||
|
||||
@@ -105,32 +205,67 @@ export class Form15ghComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
count15G(): number {
|
||||
return this.records.filter((r) => this.formKind(r) === '15G').length;
|
||||
countGeneralBand(): number {
|
||||
return this.rows.filter((r) => r.band === 'GENERAL').length;
|
||||
}
|
||||
|
||||
count15H(): number {
|
||||
return this.records.filter((r) => this.formKind(r) === '15H').length;
|
||||
countSeniorBand(): number {
|
||||
return this.rows.filter((r) => r.band === 'SENIOR').length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The declaration in force across the register, and the provision charging the
|
||||
* tax. Both changed on 01 April 2026, so they are read from the config rather
|
||||
* than written into the template.
|
||||
*/
|
||||
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||
const fy = normaliseFy(this.rows[0]?.record.fy ?? '2026-27');
|
||||
const cfg = FY_TAX_CONFIG[fy];
|
||||
return {
|
||||
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||
section: cfg.interestTdsSection,
|
||||
statement: cfg.tdsStatementForm,
|
||||
frequency: cfg.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
countFiled(): number {
|
||||
return this.records.filter((r) => r.status === 'FILED').length;
|
||||
return this.rows.filter((r) => r.record.status === 'FILED').length;
|
||||
}
|
||||
|
||||
/** Declarations on record that the branch could not lawfully have accepted. */
|
||||
countIneligible(): number {
|
||||
return this.rows.filter((r) => !r.eligibility.eligible).length;
|
||||
}
|
||||
|
||||
totalInterest(): number {
|
||||
return this.filteredRecords.reduce((s, r) => s + r.estimatedInterest, 0);
|
||||
return this.filteredRows.reduce((s, r) => s + r.record.estimatedInterest, 0);
|
||||
}
|
||||
|
||||
/** Tax the declarations kept out of customers' pockets, across the filter. */
|
||||
totalTdsSaved(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.tdsSaved, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.kindFilter = '';
|
||||
this.bandFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.ineligibleOnly = false;
|
||||
}
|
||||
|
||||
viewCertificate(r: Form15ghRecord): void {
|
||||
toggleExpanded(row: Form15ghRow): void {
|
||||
this.expandedId = this.expandedId === row.record.customerId ? null : row.record.customerId;
|
||||
}
|
||||
|
||||
isExpanded(row: Form15ghRow): boolean {
|
||||
return this.expandedId === row.record.customerId;
|
||||
}
|
||||
|
||||
viewCertificate(row: Form15ghRow): void {
|
||||
const data: CertificatePreviewDialogData = {
|
||||
kind: 'form15gh',
|
||||
form15gh: { record: r, kind: this.formKind(r), age: this.age(r.dob) }
|
||||
form15gh: { record: row.record, kind: row.kind, age: row.age }
|
||||
};
|
||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||
width: '880px',
|
||||
@@ -142,20 +277,34 @@ export class Form15ghComponent implements OnInit {
|
||||
});
|
||||
}
|
||||
|
||||
downloadCertificate(r: Form15ghRecord): void {
|
||||
const kind = this.formKind(r);
|
||||
downloadCertificate(row: Form15ghRow): void {
|
||||
const r = row.record;
|
||||
// A declaration the branch cannot accept must not go out as a signed PDF.
|
||||
if (!row.eligibility.eligible) {
|
||||
this.snack.open(`Cannot issue: ${row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||
return;
|
||||
}
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `Form 121 (${kind}) Declaration`,
|
||||
title: `${row.form === '121' ? 'Form 121' : `Form ${row.form}`} Declaration`,
|
||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm15ghPdf(r, kind, dsc)
|
||||
(dsc) => downloadForm15ghPdf(r, row.kind, dsc)
|
||||
);
|
||||
}
|
||||
|
||||
toggleViewMode(): void {
|
||||
this.viewMode = this.viewMode === 'demo' ? 'real' : 'demo';
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────── Mock seed (top-level export) ─────────────────────
|
||||
// ───────────────────────── Mock seed (top-level export) ─────────────────────
|
||||
/**
|
||||
* Two of these customers are deliberately ineligible — their estimated income
|
||||
* leaves a tax liability, so a Form 121 declaration cannot be accepted from them.
|
||||
* That gives the register's eligibility warnings something to show.
|
||||
*/
|
||||
export function seedForm15gh(): Form15ghRecord[] {
|
||||
return [
|
||||
{
|
||||
@@ -165,86 +314,109 @@ export function seedForm15gh(): Form15ghRecord[] {
|
||||
customerAddress: 'Flat 902, Skyline Apt, Andheri (E), Mumbai 400069',
|
||||
dob: '1985-02-14',
|
||||
branch: 'Mumbai — Andheri (E)',
|
||||
estimatedInterest: 38500,
|
||||
estimatedIncome: 850000,
|
||||
depositPrincipal: 500000,
|
||||
fy: 'FY 2025-26',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-10',
|
||||
assessed: true
|
||||
estimatedInterest: 38_500,
|
||||
// Over the basic exemption limit — not eligible for 15G.
|
||||
estimatedIncome: 850_000,
|
||||
depositPrincipal: 500_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST556677',
|
||||
customerName: 'Karthik Rao',
|
||||
customerPan: 'PQRSR2233Z',
|
||||
customerPan: 'PQRPR2233Z',
|
||||
customerAddress: '24, MG Road, Bengaluru 560001',
|
||||
dob: '1972-09-04',
|
||||
branch: 'Bengaluru — MG Road',
|
||||
estimatedInterest: 52800,
|
||||
estimatedIncome: 320000,
|
||||
depositPrincipal: 700000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 52_800,
|
||||
estimatedIncome: 320_000,
|
||||
depositPrincipal: 700_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: false
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST667788',
|
||||
customerName: 'Lakshmi Naidu',
|
||||
customerPan: 'XYZAN7788Q',
|
||||
customerPan: 'XYZPN7788Q',
|
||||
customerAddress: 'Plot 14, Road No. 3, Banjara Hills, Hyderabad 500034',
|
||||
dob: '1958-06-22',
|
||||
branch: 'Hyderabad — Banjara Hills',
|
||||
estimatedInterest: 88200,
|
||||
estimatedIncome: 410000,
|
||||
depositPrincipal: 1200000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 88_200,
|
||||
estimatedIncome: 410_000,
|
||||
depositPrincipal: 1_200_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-15',
|
||||
assessed: true
|
||||
filedOn: '2026-04-15',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST334455',
|
||||
customerName: 'Anita Verma',
|
||||
customerPan: 'EFGHV5678A',
|
||||
customerPan: 'EFGPV5678A',
|
||||
customerAddress: 'B-12, Connaught Pl, New Delhi 110001',
|
||||
dob: '1962-11-30',
|
||||
branch: 'New Delhi — Connaught Place',
|
||||
estimatedInterest: 64500,
|
||||
estimatedIncome: 280000,
|
||||
depositPrincipal: 900000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 64_500,
|
||||
estimatedIncome: 280_000,
|
||||
depositPrincipal: 900_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-05',
|
||||
assessed: false
|
||||
filedOn: '2026-04-05',
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.annual'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST990011',
|
||||
customerName: 'Arjun Banerjee',
|
||||
customerPan: 'TUVWB4455F',
|
||||
customerPan: 'TUVPB4455F',
|
||||
customerAddress: '42, Park Street, Kolkata 700016',
|
||||
dob: '1991-07-19',
|
||||
branch: 'Kolkata — Park Street',
|
||||
estimatedInterest: 14200,
|
||||
estimatedIncome: 620000,
|
||||
depositPrincipal: 200000,
|
||||
fy: 'FY 2025-26',
|
||||
estimatedInterest: 14_200,
|
||||
// Over the basic exemption limit — not eligible for 15G.
|
||||
estimatedIncome: 620_000,
|
||||
depositPrincipal: 200_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.monthly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST330044',
|
||||
customerName: 'Madhavi Singh',
|
||||
customerPan: 'TUVWS3344K',
|
||||
customerPan: 'TUVPS3344K',
|
||||
customerAddress: 'D-5, Greater Kailash, New Delhi 110048',
|
||||
dob: '1955-03-08',
|
||||
branch: 'New Delhi — Connaught Place',
|
||||
estimatedInterest: 145000,
|
||||
estimatedIncome: 320000,
|
||||
depositPrincipal: 2000000,
|
||||
fy: 'FY 2025-26',
|
||||
// A 15H declarant faces no ceiling on interest — only a nil liability.
|
||||
estimatedInterest: 145_000,
|
||||
estimatedIncome: 320_000,
|
||||
depositPrincipal: 2_000_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'FILED',
|
||||
filedOn: '2025-04-02',
|
||||
assessed: false
|
||||
filedOn: '2026-04-02',
|
||||
assessed: false,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
},
|
||||
{
|
||||
customerId: 'CUST778899',
|
||||
customerName: 'Vikram Malhotra',
|
||||
customerPan: '',
|
||||
customerAddress: 'Flat 405, Green Glen Layout, Outer Ring Road, Bengaluru 560103',
|
||||
dob: '1980-05-15',
|
||||
branch: 'Bengaluru — MG Road',
|
||||
estimatedInterest: 60_000,
|
||||
estimatedIncome: 350_000,
|
||||
depositPrincipal: 800_000,
|
||||
fy: 'FY 2026-27',
|
||||
status: 'PENDING',
|
||||
assessed: true,
|
||||
interestPostingPeriodType: 'savingsPostingInterestPeriodType.quarterly'
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
+329
@@ -0,0 +1,329 @@
|
||||
<!--
|
||||
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/.
|
||||
-->
|
||||
|
||||
@if (loading) {
|
||||
<div class="cert-empty">
|
||||
<fa-icon icon="spinner" [spin]="true" size="2x" style="color: #7c3aed; margin-bottom: 12px;"></fa-icon>
|
||||
<div>Loading live declarations and accounts from Fineract…</div>
|
||||
</div>
|
||||
} @else if (loadError) {
|
||||
<div class="cert-empty" style="color: #ef4444;">
|
||||
<fa-icon icon="exclamation-circle" size="2x" style="margin-bottom: 12px;"></fa-icon>
|
||||
<div>Could not load real data from Fineract. Check connection.</div>
|
||||
</div>
|
||||
} @else {
|
||||
<!-- KPI strip -->
|
||||
<div class="cert-kpis">
|
||||
<div class="cert-kpi cert-kpi--blue">
|
||||
<fa-icon icon="user" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countGeneralBand() | number }}</div>
|
||||
<div class="cert-kpi-l">General band (under 60)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="user-tie" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countSeniorBand() | number }}</div>
|
||||
<div class="cert-kpi-l">Senior citizen band (60+)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="check-circle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countFiled() | number }}</div>
|
||||
<div class="cert-kpi-l">Filed this FY</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--teal">
|
||||
<fa-icon icon="coins" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalInterest() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">Projected Interest</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cert-kpi cert-kpi--green">
|
||||
<fa-icon icon="shield-alt" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">₹{{ totalTdsSaved() | number: '1.0-0' }}</div>
|
||||
<div class="cert-kpi-l">TDS Saved</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (countIneligible() > 0) {
|
||||
<div class="cert-kpi cert-kpi--amber">
|
||||
<fa-icon icon="exclamation-triangle" class="cert-kpi-ic"></fa-icon>
|
||||
<div>
|
||||
<div class="cert-kpi-n">{{ countIneligible() | number }}</div>
|
||||
<div class="cert-kpi-l">Not Eligible</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="cert-filters">
|
||||
<div class="cert-filter">
|
||||
<label>Financial Year</label>
|
||||
<select [(ngModel)]="selectedFy" (ngModelChange)="loadRealData()">
|
||||
@for (y of availableFys; track y) {
|
||||
<option [value]="y">{{ y }}</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Customer / Customer ID / PAN</label>
|
||||
<div class="cert-input-ic">
|
||||
<fa-icon icon="search"></fa-icon>
|
||||
<input type="text" [(ngModel)]="search" placeholder="Search live data..." />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Threshold Band</label>
|
||||
<select [(ngModel)]="bandFilter">
|
||||
<option value="">All bands</option>
|
||||
<option value="GENERAL">General (below 60)</option>
|
||||
<option value="SENIOR">Senior citizen (60+)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Status</label>
|
||||
<select [(ngModel)]="statusFilter">
|
||||
<option value="">All statuses</option>
|
||||
<option value="FILED">Filed</option>
|
||||
<option value="PENDING">Pending</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="cert-filter">
|
||||
<label>Eligibility</label>
|
||||
<select [(ngModel)]="ineligibleOnly">
|
||||
<option [ngValue]="false">All customers</option>
|
||||
<option [ngValue]="true">Not eligible only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@if (search || bandFilter || statusFilter || ineligibleOnly) {
|
||||
<button type="button" class="cert-clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="cert-card">
|
||||
<div class="cert-table-wrap">
|
||||
<table class="cert-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Customer · PAN</th>
|
||||
<th>Form · Band</th>
|
||||
<th>Age</th>
|
||||
<th>Branch</th>
|
||||
<th class="num">Est. Interest</th>
|
||||
<th class="num">Threshold</th>
|
||||
<th class="num">TDS Payable</th>
|
||||
<th class="num">TDS Saved</th>
|
||||
<th>Settlement</th>
|
||||
<th>Status</th>
|
||||
<th class="col-actions">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (row of filteredRows; track row.row.record.customerId) {
|
||||
<tr [class.cert-row--warn]="!row.row.eligibility.eligible">
|
||||
<td>
|
||||
<div class="cert-cust-name">{{ row.row.record.customerName }}</div>
|
||||
<div class="mono small muted">{{ row.row.record.customerId }} · PAN {{ row.row.record.customerPan || '—' }}</div>
|
||||
<div class="small muted">DOB {{ row.row.record.dob | date: 'd MMM y' }}</div>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
class="cert-pill"
|
||||
[class.cert-pill--blue]="row.row.band === 'GENERAL'"
|
||||
[class.cert-pill--amber]="row.row.band === 'SENIOR'"
|
||||
>
|
||||
{{ row.row.form === '121' ? 'Form 121' : 'Form ' + row.row.form }}
|
||||
</span>
|
||||
<div class="small muted">{{ row.row.band === 'SENIOR' ? 'Senior citizen' : 'General' }}</div>
|
||||
</td>
|
||||
<td class="small">{{ row.row.age }} yrs</td>
|
||||
<td class="small">{{ row.row.record.branch || '—' }}</td>
|
||||
<td class="num">
|
||||
<strong class="cert-interest">₹{{ row.row.record.estimatedInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td class="num small muted">₹{{ row.row.schedule.threshold | number: '1.0-0' }}</td>
|
||||
<td class="num">
|
||||
@if (row.row.schedule.totalTds > 0) {
|
||||
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
<div class="small muted">@ {{ row.row.schedule.rateApplied * 100 | number: '1.0-0' }}%</div>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="num">
|
||||
@if (row.row.tdsSaved > 0) {
|
||||
<strong class="cert-interest">₹{{ row.row.tdsSaved | number: '1.0-0' }}</strong>
|
||||
} @else {
|
||||
<span class="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small">
|
||||
<span class="cert-pill" [class.cert-pill--blue]="row.row.mode === 'QUARTERLY'">
|
||||
{{ row.row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}
|
||||
</span>
|
||||
<div class="small muted">product-driven</div>
|
||||
</td>
|
||||
<td>
|
||||
@if (row.row.record.status === 'FILED') {
|
||||
<span class="cert-pill cert-pill--green"> <fa-icon icon="check-circle"></fa-icon> Filed </span>
|
||||
@if (row.row.record.filedOn) {
|
||||
<div class="mono small muted">{{ row.row.record.filedOn | date: 'd MMM y' }}</div>
|
||||
}
|
||||
} @else {
|
||||
<span class="cert-pill cert-pill--amber"> <fa-icon icon="hourglass-half"></fa-icon> Pending </span>
|
||||
}
|
||||
@if (!row.row.eligibility.eligible) {
|
||||
<div class="cert-pill cert-pill--amber">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon> Not eligible
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
<td class="col-actions">
|
||||
<div class="cert-row-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="toggleExpanded(row)"
|
||||
[matTooltip]="isExpanded(row) ? 'Hide quarter-wise' : 'Show quarter-wise'"
|
||||
>
|
||||
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn primary"
|
||||
(click)="viewCertificate(row)"
|
||||
matTooltip="View / Print declaration"
|
||||
>
|
||||
<fa-icon icon="eye"></fa-icon>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="cert-icon-btn"
|
||||
(click)="downloadCertificate(row)"
|
||||
matTooltip="Download PDF"
|
||||
>
|
||||
<fa-icon icon="download"></fa-icon>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (isExpanded(row)) {
|
||||
<tr class="cert-row--detail">
|
||||
<td colspan="11">
|
||||
@if (!row.row.eligibility.eligible) {
|
||||
<div class="cert-note cert-note--warn">
|
||||
<fa-icon icon="exclamation-triangle"></fa-icon>
|
||||
<div>
|
||||
<strong>Declaration cannot be accepted</strong>
|
||||
<ul>
|
||||
@for (reason of row.row.eligibility.reasons; track reason) {
|
||||
<li>{{ reason }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.row.eligibility.unassessed; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
@for (note of row.row.schedule.notes; track note) {
|
||||
<div class="cert-note">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
<div>{{ note }}</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<table class="cert-table cert-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.row.schedule.lines; track line.quarter) {
|
||||
<tr [class.cert-row--warn]="line.thresholdCrossedHere">
|
||||
<td class="small">
|
||||
{{ line.label }}
|
||||
@if (line.thresholdCrossedHere) {
|
||||
<span class="cert-pill cert-pill--amber">threshold crossed</span>
|
||||
}
|
||||
@if (line.declarationActive) {
|
||||
<span class="cert-pill cert-pill--green">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="small muted">—</span>
|
||||
}
|
||||
</td>
|
||||
<td class="small muted">{{ line.dueDate }}</td>
|
||||
</tr>
|
||||
}
|
||||
<tr>
|
||||
<td class="small"><strong>Total</strong></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.row.schedule.totalInterest | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num">
|
||||
<strong>₹{{ row.row.schedule.totalTds | number: '1.0-0' }}</strong>
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@if (row.row.schedule.shieldedInterest > 0) {
|
||||
<div class="small muted">
|
||||
₹{{ row.row.schedule.shieldedInterest | number: '1.0-0' }} paid without deduction on the strength of
|
||||
the declaration — reportable in the quarterly {{ statute.statement }} return.
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
}
|
||||
@if (filteredRows.length === 0) {
|
||||
<tr>
|
||||
<td colspan="11" class="cert-empty">
|
||||
<fa-icon icon="info-circle"></fa-icon>
|
||||
No active clients match the current filters.
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+502
@@ -0,0 +1,502 @@
|
||||
/**
|
||||
* 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, OnDestroy, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
|
||||
import { MatDialog } from '@angular/material/dialog';
|
||||
import { MatSnackBar } from '@angular/material/snack-bar';
|
||||
import { MatTooltip } from '@angular/material/tooltip';
|
||||
import { forkJoin, of, Observable, Subject } from 'rxjs';
|
||||
import { catchError, map, shareReplay, switchMap, takeUntil } from 'rxjs/operators';
|
||||
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
|
||||
|
||||
import { ClientsService } from '../../../../clients/clients.service';
|
||||
import { SavingsService } from '../../../../savings/savings.service';
|
||||
import { FixedDepositsService } from '../../../../deposits/fixed-deposits/fixed-deposits.service';
|
||||
import { RecurringDepositsService } from '../../../../deposits/recurring-deposits/recurring-deposits.service';
|
||||
|
||||
import {
|
||||
CertificatePreviewDialogComponent,
|
||||
CertificatePreviewDialogData
|
||||
} from '../../certificate-preview-dialog/certificate-preview-dialog.component';
|
||||
import { downloadForm15ghPdf } from '../../certificate-pdf';
|
||||
import { DscSignService } from '../../../dsc-sign-dialog/dsc-sign.service';
|
||||
|
||||
import {
|
||||
Declaration,
|
||||
DeclarationForm,
|
||||
EligibilityResult,
|
||||
FY_TAX_CONFIG,
|
||||
Form15Kind,
|
||||
TdsMode,
|
||||
TdsSchedule,
|
||||
ageInFy,
|
||||
buildSchedule,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
declarationFormFor,
|
||||
formKindFor,
|
||||
isSeniorInFy,
|
||||
normaliseFy,
|
||||
projectQuarterlyCredits,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor,
|
||||
isTdsApplicable,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
fyOf
|
||||
} from '../tds-15gh.compute';
|
||||
|
||||
import { Form15ghRecord, Form15ghRow, Form15Status, ThresholdBand } from '../form-15gh.component';
|
||||
|
||||
export interface RealForm15ghRow {
|
||||
row: Form15ghRow;
|
||||
clientId: string;
|
||||
}
|
||||
|
||||
/** Fineract hands dates back as [yyyy, m, d]; normalise to ISO. */
|
||||
function toIso(value: unknown): string | null {
|
||||
if (Array.isArray(value) && value.length >= 3) {
|
||||
const [y, m, d] = value as number[];
|
||||
return `${y}-${`${m}`.padStart(2, '0')}-${`${d}`.padStart(2, '0')}`;
|
||||
}
|
||||
if (typeof value === 'string' && value.trim()) {
|
||||
const parsed = new Date(value);
|
||||
if (!isNaN(parsed.getTime())) {
|
||||
return parsed.toISOString().slice(0, 10);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'mifosx-real-form15gh-view',
|
||||
templateUrl: './real-form15gh-view.component.html',
|
||||
styleUrls: ['../../certificates.shared.scss'],
|
||||
imports: [
|
||||
...STANDALONE_SHARED_IMPORTS,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FaIconComponent,
|
||||
MatTooltip
|
||||
]
|
||||
})
|
||||
export class RealForm15ghViewComponent implements OnInit, OnDestroy {
|
||||
private dialog = inject(MatDialog);
|
||||
private snack = inject(MatSnackBar);
|
||||
private clientsService = inject(ClientsService);
|
||||
private savingsService = inject(SavingsService);
|
||||
private fixedDepositsService = inject(FixedDepositsService);
|
||||
private recurringDepositsService = inject(RecurringDepositsService);
|
||||
private dscSign = inject(DscSignService);
|
||||
|
||||
private destroy$ = new Subject<void>();
|
||||
|
||||
loading = true;
|
||||
loadError = false;
|
||||
rows: RealForm15ghRow[] = [];
|
||||
|
||||
// ── Filters ──
|
||||
search = '';
|
||||
bandFilter: '' | ThresholdBand = '';
|
||||
statusFilter: '' | Form15Status = '';
|
||||
ineligibleOnly = false;
|
||||
|
||||
/** Which row's quarter-wise breakdown is open. */
|
||||
expandedId: string | null = null;
|
||||
selectedFy = 'FY 2026-27';
|
||||
|
||||
// Per-client caches
|
||||
private clientDataCache = new Map<string, Observable<any>>();
|
||||
private clientKycCache = new Map<string, Observable<any>>();
|
||||
private clientAccountsCache = new Map<string, Observable<any>>();
|
||||
|
||||
readonly availableFys = ['FY 2026-27', 'FY 2025-26'];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadRealData();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.destroy$.next();
|
||||
this.destroy$.complete();
|
||||
}
|
||||
|
||||
loadRealData(): void {
|
||||
this.loading = true;
|
||||
this.loadError = false;
|
||||
this.clientDataCache.clear();
|
||||
this.clientKycCache.clear();
|
||||
this.clientAccountsCache.clear();
|
||||
|
||||
// Fetch up to 100 active individual clients.
|
||||
this.clientsService
|
||||
.searchByText('', 0, 100, '', '', true, 'INDIVIDUAL', true)
|
||||
.pipe(
|
||||
takeUntil(this.destroy$),
|
||||
map((page: any) => page?.content || page?.pageItems || (Array.isArray(page) ? page : [])),
|
||||
switchMap((clients: any[]) => {
|
||||
if (!clients.length) {
|
||||
return of([] as RealForm15ghRow[]);
|
||||
}
|
||||
return forkJoin(clients.map((c) => this.buildRealRow(c))).pipe(
|
||||
map((rows) => rows.filter((r): r is RealForm15ghRow => r !== null))
|
||||
);
|
||||
}),
|
||||
catchError(() => {
|
||||
this.loadError = true;
|
||||
return of([] as RealForm15ghRow[]);
|
||||
})
|
||||
)
|
||||
.subscribe((rows) => {
|
||||
this.rows = rows;
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
|
||||
private buildRealRow(clientSummary: any): Observable<RealForm15ghRow | null> {
|
||||
const clientId = clientSummary?.id != null ? String(clientSummary.id) : null;
|
||||
if (!clientId) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return forkJoin({
|
||||
client: this.getClientDataCached(clientId),
|
||||
kyc: this.getClientKycCached(clientId),
|
||||
accountsSummary: this.getClientAccountsCached(clientId)
|
||||
}).pipe(
|
||||
switchMap(({ client, kyc, accountsSummary }) => {
|
||||
const activeSavings = (accountsSummary?.savingsAccounts ?? []).filter(
|
||||
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
|
||||
);
|
||||
|
||||
if (!activeSavings.length) {
|
||||
// If no accounts, we can't calculate interest. But we can still build an empty row if a declaration is filed
|
||||
return of({ client, kyc, accountDetailsList: [] });
|
||||
}
|
||||
|
||||
return forkJoin(activeSavings.map((a: any) => this.fetchAccountDetails(a))).pipe(
|
||||
map((detailsList) => ({
|
||||
client,
|
||||
kyc,
|
||||
accountDetailsList: (detailsList as any[]).filter(Boolean)
|
||||
}))
|
||||
);
|
||||
}),
|
||||
map(({ client, kyc, accountDetailsList }) => {
|
||||
if (!client) {
|
||||
return null;
|
||||
}
|
||||
const record = this.mapToRecord(client, kyc, accountDetailsList);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const row = this.computeRow(record, accountDetailsList);
|
||||
return {
|
||||
row,
|
||||
clientId
|
||||
};
|
||||
}),
|
||||
catchError(() => of(null))
|
||||
);
|
||||
}
|
||||
|
||||
private getClientDataCached(clientId: string): Observable<any> {
|
||||
if (!this.clientDataCache.has(clientId)) {
|
||||
this.clientDataCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientData(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientDataCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientKycCached(clientId: string): Observable<any> {
|
||||
if (!this.clientKycCache.has(clientId)) {
|
||||
this.clientKycCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientKyc(clientId, true).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientKycCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private getClientAccountsCached(clientId: string): Observable<any> {
|
||||
if (!this.clientAccountsCache.has(clientId)) {
|
||||
this.clientAccountsCache.set(
|
||||
clientId,
|
||||
this.clientsService.getClientAccountData(clientId).pipe(
|
||||
catchError(() => of(null)),
|
||||
shareReplay(1)
|
||||
)
|
||||
);
|
||||
}
|
||||
return this.clientAccountsCache.get(clientId)!;
|
||||
}
|
||||
|
||||
private fetchAccountDetails(accountSummary: any): Observable<any> {
|
||||
const id = String(accountSummary.id);
|
||||
const code = accountSummary.depositType?.code ?? '';
|
||||
let call: Observable<any>;
|
||||
if (code.includes('fixedDeposit')) {
|
||||
call = this.fixedDepositsService.getFixedDepositsAccountData(id);
|
||||
} else if (code.includes('recurringDeposit')) {
|
||||
call = this.recurringDepositsService.getRecurringDepositsAccountData(id);
|
||||
} else {
|
||||
call = this.savingsService.getSavingsAccountData(id);
|
||||
}
|
||||
return call.pipe(catchError(() => of(null)));
|
||||
}
|
||||
|
||||
private mapToRecord(client: any, kyc: any, detailsList: any[]): Form15ghRecord | null {
|
||||
const dob = toIso(client?.dateOfBirth) ?? '';
|
||||
if (!dob) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fy = normaliseFy(this.selectedFy);
|
||||
const hasDeclaration = !!kyc?.form15Applicable && normaliseFy(kyc?.form15Fy ?? '') === fy;
|
||||
const status: Form15Status = hasDeclaration ? 'FILED' : 'PENDING';
|
||||
|
||||
// Sum estimated time deposit interest for the selected year
|
||||
let estimatedInterest = 0;
|
||||
let depositPrincipal = 0;
|
||||
|
||||
for (const d of detailsList) {
|
||||
const code = d.depositType?.code ?? '';
|
||||
const isTimeDeposit = code.includes('fixedDeposit') || code.includes('recurringDeposit');
|
||||
if (isTimeDeposit) {
|
||||
depositPrincipal += d.depositAmount ?? d.nominalAnnualInterestRate ?? 0;
|
||||
// Sum interest transactions in the selected FY
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (iso && fyOf(iso) === fy) {
|
||||
estimatedInterest += t.amount ?? 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to a small placeholder principal/interest if the live accounts have no transaction history yet,
|
||||
// so the table is not completely blank of figures.
|
||||
if (estimatedInterest === 0 && depositPrincipal > 0) {
|
||||
estimatedInterest = Math.round(depositPrincipal * 0.07);
|
||||
}
|
||||
|
||||
return {
|
||||
customerId: client.accountNo || String(client.id),
|
||||
customerName: client.displayName || '',
|
||||
customerPan: (kyc?.panNumber ?? '').toUpperCase(),
|
||||
customerAddress: client.officeName || '',
|
||||
dob,
|
||||
branch: client.officeName || '',
|
||||
estimatedInterest,
|
||||
estimatedIncome: kyc?.estimatedIncome ?? 0,
|
||||
depositPrincipal,
|
||||
fy: this.selectedFy,
|
||||
status,
|
||||
filedOn: hasDeclaration ? toIso(kyc?.submittedOnDate) ?? `${fy.slice(0, 4)}-04-01` : undefined,
|
||||
assessed: true,
|
||||
interestPostingPeriodType: detailsList.find(d => d.depositType?.code?.includes('fixedDeposit'))?.interestPostingPeriodType?.code || 'savingsPostingInterestPeriodType.quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
private computeRow(record: Form15ghRecord, detailsList: any[]): Form15ghRow {
|
||||
const ctx = {
|
||||
fy: record.fy,
|
||||
dateOfBirth: record.dob,
|
||||
pan: record.customerPan,
|
||||
resident: true
|
||||
};
|
||||
|
||||
const declaration: Declaration | null =
|
||||
record.status === 'FILED'
|
||||
? {
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
acceptedOn: record.filedOn ?? `${record.fy.replace(/\D/g, '').slice(0, 4)}-04-01`,
|
||||
fy: record.fy
|
||||
}
|
||||
: null;
|
||||
|
||||
const mode = tdsModeFor({ interestPostingPeriodType: record.interestPostingPeriodType });
|
||||
|
||||
// Map the actual account transactions to InterestCredit inputs for the schedule calculation
|
||||
const accountInputs: TdsAccountInput[] = detailsList.map((d) => {
|
||||
const code = d.depositType?.code ?? '';
|
||||
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
|
||||
const ref = {
|
||||
id: d.id,
|
||||
accountNo: d.accountNo ?? String(d.id),
|
||||
depositTypeCode: code,
|
||||
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
|
||||
clientId: d.clientId ?? Number(record.customerId),
|
||||
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
|
||||
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
|
||||
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
|
||||
interestPostingPeriodType: d.interestPostingPeriodType ?? null
|
||||
};
|
||||
|
||||
const credits: InterestCredit[] = [];
|
||||
for (const t of d.transactions ?? []) {
|
||||
if (t.reversed || t.isReversal || !t.transactionType?.interestPosting) {
|
||||
continue;
|
||||
}
|
||||
const iso = toIso(t.date);
|
||||
if (iso) {
|
||||
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...ref,
|
||||
credits
|
||||
};
|
||||
});
|
||||
|
||||
const collectedCredits = collectTdsCredits(Number(record.customerId), accountInputs, record.fy);
|
||||
// If no real transactions are recorded yet, fall back to spreading the estimated interest
|
||||
const creditsToUse = collectedCredits.length
|
||||
? collectedCredits
|
||||
: projectQuarterlyCredits(record.estimatedInterest, record.fy);
|
||||
|
||||
const schedule = buildSchedule(mode, creditsToUse, { ...ctx, declaration });
|
||||
const withoutDeclaration = buildSchedule(mode, creditsToUse, { ...ctx, declaration: null });
|
||||
|
||||
return {
|
||||
record,
|
||||
form: declarationFormFor(record.fy, record.dob),
|
||||
kind: formKindFor(record.dob, record.fy),
|
||||
band: isSeniorInFy(record.dob, record.fy) ? 'SENIOR' : 'GENERAL',
|
||||
age: ageInFy(record.dob, record.fy),
|
||||
mode,
|
||||
schedule,
|
||||
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
|
||||
eligibility: checkEligibility(ctx, {
|
||||
estimatedInterest: record.estimatedInterest,
|
||||
estimatedIncome: record.estimatedIncome || 250000,
|
||||
incomeOnRecord: true
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
get filteredRows(): RealForm15ghRow[] {
|
||||
const q = this.search.trim().toLowerCase();
|
||||
return this.rows.filter(({ row }) => {
|
||||
if (this.bandFilter && row.band !== this.bandFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.statusFilter && row.record.status !== this.statusFilter) {
|
||||
return false;
|
||||
}
|
||||
if (this.ineligibleOnly && row.eligibility.eligible) {
|
||||
return false;
|
||||
}
|
||||
if (!q) {
|
||||
return true;
|
||||
}
|
||||
const r = row.record;
|
||||
return (
|
||||
r.customerName.toLowerCase().includes(q) ||
|
||||
r.customerId.toLowerCase().includes(q) ||
|
||||
r.customerPan.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
countGeneralBand(): number {
|
||||
return this.rows.filter((r) => r.row.band === 'GENERAL').length;
|
||||
}
|
||||
|
||||
countSeniorBand(): number {
|
||||
return this.rows.filter((r) => r.row.band === 'SENIOR').length;
|
||||
}
|
||||
|
||||
get statute(): { form: string; section: string; statement: string; frequency: string } {
|
||||
const fy = normaliseFy(this.selectedFy);
|
||||
const cfg = FY_TAX_CONFIG[fy];
|
||||
return {
|
||||
form: cfg?.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
|
||||
section: cfg?.interestTdsSection ?? '—',
|
||||
statement: cfg?.tdsStatementForm ?? '—',
|
||||
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
|
||||
};
|
||||
}
|
||||
|
||||
countFiled(): number {
|
||||
return this.rows.filter((r) => r.row.record.status === 'FILED').length;
|
||||
}
|
||||
|
||||
countIneligible(): number {
|
||||
return this.rows.filter((r) => !r.row.eligibility.eligible).length;
|
||||
}
|
||||
|
||||
totalInterest(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.row.record.estimatedInterest, 0);
|
||||
}
|
||||
|
||||
totalTdsSaved(): number {
|
||||
return this.filteredRows.reduce((s, r) => s + r.row.tdsSaved, 0);
|
||||
}
|
||||
|
||||
clearFilters(): void {
|
||||
this.search = '';
|
||||
this.bandFilter = '';
|
||||
this.statusFilter = '';
|
||||
this.ineligibleOnly = false;
|
||||
}
|
||||
|
||||
toggleExpanded(row: RealForm15ghRow): void {
|
||||
this.expandedId = this.expandedId === row.row.record.customerId ? null : row.row.record.customerId;
|
||||
}
|
||||
|
||||
isExpanded(row: RealForm15ghRow): boolean {
|
||||
return this.expandedId === row.row.record.customerId;
|
||||
}
|
||||
|
||||
viewCertificate(row: RealForm15ghRow): void {
|
||||
const data: CertificatePreviewDialogData = {
|
||||
kind: 'form15gh',
|
||||
form15gh: { record: row.row.record, kind: row.row.kind, age: row.row.age }
|
||||
};
|
||||
this.dialog.open(CertificatePreviewDialogComponent, {
|
||||
width: '880px',
|
||||
maxWidth: '96vw',
|
||||
maxHeight: '92vh',
|
||||
autoFocus: false,
|
||||
panelClass: 'cert-preview-dialog',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
downloadCertificate(row: RealForm15ghRow): void {
|
||||
const r = row.row.record;
|
||||
if (!row.row.eligibility.eligible) {
|
||||
this.snack.open(`Cannot issue: ${row.row.eligibility.reasons[0]}`, 'Dismiss', { duration: 6000 });
|
||||
return;
|
||||
}
|
||||
this.dscSign.promptThenDownload(
|
||||
{
|
||||
title: `${row.row.form === '121' ? 'Form 121' : `Form ${row.row.form}`} Declaration`,
|
||||
reference: `${r.customerId} · ${r.customerName} · ${r.fy}`,
|
||||
filename: `Form121-${r.customerId}-${r.fy.replace(/\s+/g, '')}.pdf`
|
||||
},
|
||||
(dsc) => downloadForm15ghPdf(r, row.row.kind, dsc)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* 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 {
|
||||
FY_TAX_CONFIG,
|
||||
InterestCredit,
|
||||
TdsAccountInput,
|
||||
TdsContext,
|
||||
ageInFy,
|
||||
declarationFormFor,
|
||||
annualSchedule,
|
||||
bucketByQuarter,
|
||||
checkEligibility,
|
||||
collectTdsCredits,
|
||||
cumulativeLiability,
|
||||
formKindFor,
|
||||
fyOf,
|
||||
isTdsApplicable,
|
||||
isWellFormedPan,
|
||||
lateDeductionInterest,
|
||||
monthsOrPartBetween,
|
||||
projectQuarterlyCredits,
|
||||
quarterlySchedule,
|
||||
reconcile,
|
||||
tdsDeducteeOf,
|
||||
tdsModeFor
|
||||
} from './tds-15gh.compute';
|
||||
|
||||
const FY = 'FY 2026-27';
|
||||
|
||||
/** Under 60 for FY 2026-27, valid individual PAN. */
|
||||
function general(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||
return { fy: FY, dateOfBirth: '1985-02-14', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||
}
|
||||
|
||||
/** 60+ for FY 2026-27. */
|
||||
function senior(overrides: Partial<TdsContext> = {}): TdsContext {
|
||||
return { fy: FY, dateOfBirth: '1958-06-22', pan: 'ABCPM1234E', resident: true, ...overrides };
|
||||
}
|
||||
|
||||
/** Four dated credits, one per quarter. */
|
||||
function credits(q1: number, q2: number, q3: number, q4: number): InterestCredit[] {
|
||||
return [
|
||||
{ date: '2026-06-30', amount: q1 },
|
||||
{ date: '2026-09-30', amount: q2 },
|
||||
{ date: '2026-12-31', amount: q3 },
|
||||
{ date: '2027-03-31', amount: q4 }
|
||||
];
|
||||
}
|
||||
|
||||
describe('194A threshold behaviour', () => {
|
||||
it('deducts nothing while the aggregate stays under the trigger', () => {
|
||||
expect(quarterlySchedule(credits(12_000, 12_000, 12_000, 12_000), general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('deducts nothing when the aggregate lands exactly on the trigger', () => {
|
||||
// The section bites only where the amount *exceeds* the limit.
|
||||
expect(quarterlySchedule(credits(12_500, 12_500, 12_500, 12_500), general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('treats the threshold as a trigger, not an allowance', () => {
|
||||
// ₹52,000 of interest: 10% of the whole ₹52,000, not 10% of the ₹2,000 above
|
||||
// the ₹50,000 line.
|
||||
const s = annualSchedule(credits(13_000, 13_000, 13_000, 13_000), general());
|
||||
expect(s.totalTds).toBe(5_200);
|
||||
expect(s.totalTds).not.toBe(200);
|
||||
});
|
||||
|
||||
it('applies the senior citizen trigger of ₹1,00,000', () => {
|
||||
const c = credits(20_000, 20_000, 20_000, 20_000); // ₹80,000
|
||||
expect(quarterlySchedule(c, general()).totalTds).toBe(8_000);
|
||||
expect(quarterlySchedule(c, senior()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('falls back to the 206AA rate of 20% where PAN is not on record', () => {
|
||||
const c = credits(25_000, 25_000, 25_000, 25_000); // ₹1,00,000
|
||||
expect(annualSchedule(c, general()).totalTds).toBe(10_000);
|
||||
expect(annualSchedule(c, general({ pan: '' })).totalTds).toBe(20_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the quarterly schedule catches up in the quarter the trigger is crossed', () => {
|
||||
it('loads the whole year-to-date liability into that quarter', () => {
|
||||
// ₹20,000 a quarter. The aggregate crosses ₹50,000 during Q3.
|
||||
const s = quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general());
|
||||
expect(s.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
0,
|
||||
6_000,
|
||||
2_000
|
||||
]);
|
||||
expect(s.lines[2].thresholdCrossedHere).toBe(true);
|
||||
// Q3 deducts ₹6,000 against ₹20,000 of that quarter's own interest. That is
|
||||
// correct: the earlier quarters became liable the moment the line was
|
||||
// crossed, and the branch collects the arrears now.
|
||||
expect(s.totalTds).toBe(8_000);
|
||||
});
|
||||
|
||||
it('never deducts a negative amount on a rising book', () => {
|
||||
const s = quarterlySchedule(credits(5_000, 60_000, 1_000, 400), general());
|
||||
for (const line of s.lines) {
|
||||
expect(line.tds).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('quarterly and annual settlement agree to the rupee', () => {
|
||||
const distributions: [number, number, number, number][] = [
|
||||
[
|
||||
12_000,
|
||||
12_000,
|
||||
12_000,
|
||||
12_000
|
||||
],
|
||||
[
|
||||
20_000,
|
||||
20_000,
|
||||
20_000,
|
||||
20_000
|
||||
],
|
||||
[
|
||||
60_000,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
75_000
|
||||
],
|
||||
[
|
||||
49_999,
|
||||
1,
|
||||
0,
|
||||
0
|
||||
],
|
||||
[
|
||||
13_333,
|
||||
13_333,
|
||||
13_333,
|
||||
13_334
|
||||
],
|
||||
[
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
99_999
|
||||
],
|
||||
[
|
||||
250_000,
|
||||
250_000,
|
||||
250_000,
|
||||
250_000
|
||||
]
|
||||
];
|
||||
|
||||
it.each(distributions)('holds for %i / %i / %i / %i', (q1, q2, q3, q4) => {
|
||||
const c = credits(q1, q2, q3, q4);
|
||||
for (const ctx of [
|
||||
general(),
|
||||
senior(),
|
||||
general({ pan: '' })
|
||||
]) {
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
expect(q.totalTds).toBe(a.totalTds);
|
||||
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(a.totalTds);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds under section 288B rounding to the nearest ₹10', () => {
|
||||
const ctx = general({ config: { roundTo: 10 } });
|
||||
const c = credits(13_333, 13_333, 13_333, 13_334);
|
||||
expect(quarterlySchedule(c, ctx).totalTds).toBe(annualSchedule(c, ctx).totalTds);
|
||||
expect(annualSchedule(c, ctx).totalTds % 10).toBe(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Property test. Rounding drift only shows up on awkward numbers, so sweep a
|
||||
* deterministic spread of them rather than trusting hand-picked cases.
|
||||
*/
|
||||
it('holds across 2000 pseudo-random interest distributions', () => {
|
||||
let seed = 20260801;
|
||||
const next = (max: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 4294967296) * max);
|
||||
};
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||
const ctx = general({ config: { roundTo: i % 2 === 0 ? 1 : 10 } });
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
if (q.totalTds !== a.totalTds) {
|
||||
throw new Error(
|
||||
`Mismatch on ${c.map((x) => x.amount).join('/')}: quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||
);
|
||||
}
|
||||
expect(q.lines.reduce((s, l) => s + l.tds, 0)).toBe(q.totalTds);
|
||||
}
|
||||
});
|
||||
|
||||
it('holds across 2000 distributions with a declaration at every acceptance point', () => {
|
||||
let seed = 19980731;
|
||||
const next = (max: number) => {
|
||||
seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0;
|
||||
return Math.floor((seed / 4294967296) * max);
|
||||
};
|
||||
const dates = [
|
||||
'2026-04-01',
|
||||
'2026-05-15',
|
||||
'2026-08-01',
|
||||
'2026-10-01',
|
||||
'2027-01-10',
|
||||
'2027-03-31'
|
||||
];
|
||||
|
||||
for (let i = 0; i < 2000; i++) {
|
||||
const c = credits(next(90_000), next(90_000), next(90_000), next(90_000));
|
||||
const ctx = general({
|
||||
declaration: { kind: '15G', acceptedOn: dates[i % dates.length], fy: '2026-27' },
|
||||
config: { roundTo: i % 2 === 0 ? 1 : 10 }
|
||||
});
|
||||
const q = quarterlySchedule(c, ctx);
|
||||
const a = annualSchedule(c, ctx);
|
||||
if (q.totalTds !== a.totalTds) {
|
||||
throw new Error(
|
||||
`Mismatch on ${c.map((x) => x.amount).join('/')} declared ${dates[i % dates.length]}: ` +
|
||||
`quarterly ${q.totalTds} vs annual ${a.totalTds}`
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('adjusts within the year when interest is reversed', () => {
|
||||
// Q2 took the aggregate to ₹80,000 and ₹8,000 was deducted. The reversal
|
||||
// brings the year back to ₹50,000, which does not exceed the trigger at all,
|
||||
// so the whole ₹8,000 is given back in Q3.
|
||||
const c = credits(40_000, 40_000, -30_000, 0);
|
||||
const q = quarterlySchedule(c, general());
|
||||
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
8_000,
|
||||
-8_000,
|
||||
0
|
||||
]);
|
||||
expect(q.totalTds).toBe(0);
|
||||
expect(annualSchedule(c, general()).totalTds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('a Form 121 declaration', () => {
|
||||
const fromApril = general({
|
||||
declaration: { kind: '15G', acceptedOn: '2026-04-01', fy: '2026-27', uin: 'U0001' }
|
||||
});
|
||||
const fromOctober = general({
|
||||
declaration: { kind: '15G', acceptedOn: '2026-10-01', fy: '2026-27' }
|
||||
});
|
||||
|
||||
it('stops deduction for the whole year when on record from April', () => {
|
||||
const c = credits(30_000, 30_000, 30_000, 30_000);
|
||||
expect(quarterlySchedule(c, fromApril).totalTds).toBe(0);
|
||||
expect(annualSchedule(c, fromApril).totalTds).toBe(0);
|
||||
expect(reconcile(c, fromApril).matched).toBe(true);
|
||||
});
|
||||
|
||||
it('shields only the interest credited on or after it was accepted', () => {
|
||||
const s = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(s.liableInterest).toBe(60_000);
|
||||
expect(s.shieldedInterest).toBe(60_000);
|
||||
});
|
||||
|
||||
it('does not claw back tax already remitted before it arrived', () => {
|
||||
const q = quarterlySchedule(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(q.lines.map((l) => l.tds)).toEqual([
|
||||
0,
|
||||
6_000,
|
||||
0,
|
||||
0
|
||||
]);
|
||||
expect(q.totalTds).toBe(6_000);
|
||||
expect(q.notes[0]).toContain('cannot reverse it');
|
||||
});
|
||||
|
||||
it('leaves the two modes in agreement even when taken mid-year', () => {
|
||||
// A declaration is prospective, so the year-end figure still sees the
|
||||
// pre-declaration interest as liable.
|
||||
const r = reconcile(credits(30_000, 30_000, 30_000, 30_000), fromOctober);
|
||||
expect(r.quarterlyTotal).toBe(6_000);
|
||||
expect(r.annualTotal).toBe(6_000);
|
||||
expect(r.matched).toBe(true);
|
||||
});
|
||||
|
||||
it('reports the divergence when liability falls after deductions were frozen', () => {
|
||||
// Q1 crossed the trigger and ₹6,000 went to the Government. A reversal in
|
||||
// Q3, after the declaration was on record, drags the year back under
|
||||
// ₹50,000 — the liability is now nil but the branch cannot refund.
|
||||
const c: InterestCredit[] = [
|
||||
{ date: '2026-06-30', amount: 60_000 },
|
||||
{ date: '2026-12-31', amount: -15_000 }
|
||||
];
|
||||
const r = reconcile(c, fromOctober);
|
||||
expect(r.quarterlyTotal).toBe(6_000);
|
||||
expect(r.annualTotal).toBe(0);
|
||||
expect(r.matched).toBe(false);
|
||||
expect(quarterlySchedule(c, fromOctober).lines[2].tds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('age is measured against the financial year, not today', () => {
|
||||
it('gives the senior threshold to a customer who turns 60 in March', () => {
|
||||
expect(ageInFy('1967-03-20', FY)).toBe(60);
|
||||
expect(formKindFor('1967-03-20', FY)).toBe('15H');
|
||||
const ctx = general({ dateOfBirth: '1967-03-20' });
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), ctx).threshold).toBe(100_000);
|
||||
});
|
||||
|
||||
it('keeps the general threshold for a customer who turns 60 the next April', () => {
|
||||
expect(ageInFy('1967-04-20', FY)).toBe(59);
|
||||
expect(formKindFor('1967-04-20', FY)).toBe('15G');
|
||||
});
|
||||
});
|
||||
|
||||
describe('savings interest is outside section 194A', () => {
|
||||
it.each([
|
||||
[
|
||||
'depositAccountType.fixedDeposit',
|
||||
true
|
||||
],
|
||||
[
|
||||
'depositAccountType.recurringDeposit',
|
||||
true
|
||||
],
|
||||
[
|
||||
'depositAccountType.savingsDeposit',
|
||||
false
|
||||
],
|
||||
[
|
||||
'depositAccountType.currentDeposit',
|
||||
false
|
||||
],
|
||||
[
|
||||
'',
|
||||
false
|
||||
]
|
||||
])('%s -> %s', (code, expected) => {
|
||||
expect(isTdsApplicable({ depositTypeCode: code })).toBe(expected);
|
||||
});
|
||||
|
||||
it('keeps savings interest out of the aggregate entirely', () => {
|
||||
const accounts: TdsAccountInput[] = [
|
||||
{
|
||||
accountNo: 'FD001',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: credits(10_000, 10_000, 10_000, 10_000)
|
||||
},
|
||||
{
|
||||
accountNo: 'SB001',
|
||||
depositTypeCode: 'depositAccountType.savingsDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: credits(5_000, 5_000, 5_000, 5_000)
|
||||
}
|
||||
];
|
||||
const collected = collectTdsCredits(1, accounts, FY);
|
||||
|
||||
// ₹40,000 of FD interest, not ₹60,000 — so the trigger is never crossed and
|
||||
// nothing is deducted. Folding the savings interest in would have crossed it.
|
||||
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(40_000);
|
||||
expect(quarterlySchedule(collected, general()).totalTds).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('whose PAN the tax is deducted against', () => {
|
||||
it('uses the account holder for a single-name account', () => {
|
||||
expect(tdsDeducteeOf({ accountStatus: 'SINGLE', clientId: 7 })).toBe(7);
|
||||
expect(tdsDeducteeOf({ accountStatus: 'INDIVIDUAL_MALE', clientId: 7 })).toBe(7);
|
||||
});
|
||||
|
||||
it('uses the first-named holder on a joint account', () => {
|
||||
expect(
|
||||
tdsDeducteeOf({
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }]
|
||||
})
|
||||
).toBe(7);
|
||||
});
|
||||
|
||||
it('uses the guardian on a minor account', () => {
|
||||
expect(tdsDeducteeOf({ accountStatus: 'MINOR', clientId: 11, guardianClientId: 4 })).toBe(4);
|
||||
});
|
||||
|
||||
it('does not count a joint deposit twice across the two holders', () => {
|
||||
const jointFd: TdsAccountInput = {
|
||||
accountNo: 'FD-JOINT',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }],
|
||||
credits: credits(15_000, 15_000, 15_000, 15_000) // ₹60,000
|
||||
};
|
||||
|
||||
// Primary holder carries the whole ₹60,000 and the whole ₹6,000 of tax.
|
||||
const primary = collectTdsCredits(7, [jointFd], FY);
|
||||
expect(primary.reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||
expect(quarterlySchedule(primary, general()).totalTds).toBe(6_000);
|
||||
|
||||
// The second holder carries none of it — no double deduction on one deposit.
|
||||
const secondHolder = collectTdsCredits(9, [jointFd], FY);
|
||||
expect(secondHolder).toEqual([]);
|
||||
expect(quarterlySchedule(secondHolder, general()).totalTds).toBe(0);
|
||||
});
|
||||
|
||||
it('ignores any share percentage when attributing interest', () => {
|
||||
// A 50/50 joint account still reports the full amount against the primary
|
||||
// holder; splitting would produce two deductees for one deposit.
|
||||
const jointFd: TdsAccountInput = {
|
||||
accountNo: 'FD-JOINT',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'JOINT',
|
||||
primaryClientId: 7,
|
||||
jointHolders: [{ clientId: 9 }],
|
||||
credits: credits(15_000, 15_000, 15_000, 15_000)
|
||||
};
|
||||
expect(collectTdsCredits(7, [jointFd], FY).reduce((s, c) => s + c.amount, 0)).toBe(60_000);
|
||||
});
|
||||
|
||||
it('aggregates a customer across every branch', () => {
|
||||
// Three deposits in three branches, each under the trigger on its own.
|
||||
const accounts: TdsAccountInput[] = [
|
||||
'FD-MUM',
|
||||
'FD-PUN',
|
||||
'FD-NAS'
|
||||
].map((accountNo, i) => ({
|
||||
accountNo,
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: [{ date: `2026-0${6 + i}-30`, amount: [
|
||||
20_000,
|
||||
18_000,
|
||||
15_000
|
||||
][i] }]
|
||||
}));
|
||||
|
||||
const collected = collectTdsCredits(1, accounts, FY);
|
||||
expect(collected.reduce((s, c) => s + c.amount, 0)).toBe(53_000);
|
||||
// Branch by branch none would have crossed ₹50,000. Bank-wide, it does.
|
||||
expect(quarterlySchedule(collected, general()).totalTds).toBe(5_300);
|
||||
});
|
||||
|
||||
it('keeps other financial years out of the selected year', () => {
|
||||
const account: TdsAccountInput = {
|
||||
accountNo: 'FD001',
|
||||
depositTypeCode: 'depositAccountType.fixedDeposit',
|
||||
accountStatus: 'SINGLE',
|
||||
clientId: 1,
|
||||
credits: [
|
||||
{ date: '2026-03-31', amount: 99_000 }, // FY 2025-26
|
||||
{ date: '2026-06-30', amount: 10_000 }, // FY 2026-27
|
||||
{ date: '2027-04-01', amount: 99_000 } // FY 2027-28
|
||||
]
|
||||
};
|
||||
expect(collectTdsCredits(1, [account], FY).map((c) => c.amount)).toEqual([10_000]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('settlement mode follows the product, not the customer', () => {
|
||||
it.each([
|
||||
[
|
||||
'savingsPostingInterestPeriodType.monthly',
|
||||
'QUARTERLY'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.quarterly',
|
||||
'QUARTERLY'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.annual',
|
||||
'ANNUAL'
|
||||
],
|
||||
[
|
||||
'savingsPostingInterestPeriodType.annually',
|
||||
'ANNUAL'
|
||||
]
|
||||
])('%s -> %s', (code, expected) => {
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { code } })).toBe(expected);
|
||||
});
|
||||
|
||||
it('does not mistake a half-yearly product for an annual one', () => {
|
||||
// 'biannual' contains 'annual' — a naive substring test settles half-yearly
|
||||
// products once a year, which is a deduction default.
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.biannual' } })).toBe(
|
||||
'QUARTERLY'
|
||||
);
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Bi-Annual' } })).toBe('QUARTERLY');
|
||||
});
|
||||
|
||||
it('reads the value when no code is present, and defaults safely', () => {
|
||||
expect(tdsModeFor({ interestPostingPeriodType: { value: 'Annually' } })).toBe('ANNUAL');
|
||||
// Unknown or missing configuration settles on the quarterly cycle, which is
|
||||
// the conservative choice — deducting sooner is never a default.
|
||||
expect(tdsModeFor({})).toBe('QUARTERLY');
|
||||
expect(tdsModeFor({ interestPostingPeriodType: null })).toBe('QUARTERLY');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PAN validation', () => {
|
||||
it.each([
|
||||
[
|
||||
'ABCPM1234E',
|
||||
true
|
||||
],
|
||||
[
|
||||
'EFGHV5678A',
|
||||
true
|
||||
],
|
||||
[
|
||||
'PQRSR2233Z',
|
||||
false
|
||||
],
|
||||
[
|
||||
'TUVWS3344K',
|
||||
false
|
||||
],
|
||||
[
|
||||
'ABCP1234E',
|
||||
false
|
||||
],
|
||||
[
|
||||
'',
|
||||
false
|
||||
]
|
||||
])('%s -> %s', (pan, expected) => {
|
||||
expect(isWellFormedPan(pan)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
const income = (estimatedInterest: number, estimatedIncome: number) => ({
|
||||
estimatedInterest,
|
||||
estimatedIncome,
|
||||
incomeOnRecord: true
|
||||
});
|
||||
|
||||
/** FY 2025-26 — the last year the 1961 Act and the 15G/15H split applied. */
|
||||
const OLD_FY = 'FY 2025-26';
|
||||
|
||||
describe('which declaration form is in force', () => {
|
||||
it('splits by age up to FY 2025-26', () => {
|
||||
expect(declarationFormFor(OLD_FY, '1985-02-14')).toBe('15G');
|
||||
expect(declarationFormFor(OLD_FY, '1958-06-22')).toBe('15H');
|
||||
});
|
||||
|
||||
it('is a single Form 121 from FY 2026-27, whatever the age', () => {
|
||||
expect(declarationFormFor(FY, '1985-02-14')).toBe('121');
|
||||
expect(declarationFormFor(FY, '1958-06-22')).toBe('121');
|
||||
});
|
||||
|
||||
it('keeps the senior threshold even though the forms merged', () => {
|
||||
// The two forms became one, but the deduction trigger is still age-based.
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), general()).threshold).toBe(50_000);
|
||||
expect(quarterlySchedule(credits(20_000, 20_000, 20_000, 20_000), senior()).threshold).toBe(100_000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibility up to FY 2025-26 — the 15G / 15H tests', () => {
|
||||
const old = (o: Partial<TdsContext> = {}) => general({ fy: OLD_FY, ...o });
|
||||
const oldSenior = (o: Partial<TdsContext> = {}) => senior({ fy: OLD_FY, ...o });
|
||||
|
||||
it('accepts a 15G within both the income and the interest ceiling', () => {
|
||||
const r = checkEligibility(old(), income(90_000, 320_000));
|
||||
expect(r.form).toBe('15G');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a 15G whose estimated income exceeds the basic exemption', () => {
|
||||
const r = checkEligibility(old(), income(38_500, 850_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('exceeds the basic exemption');
|
||||
});
|
||||
|
||||
it('rejects a 15G whose interest alone exceeds the basic exemption', () => {
|
||||
// The second limb: interest had to clear the limit as well as income.
|
||||
expect(checkEligibility(old(), income(450_000, 380_000)).eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('places no interest ceiling on a 15H', () => {
|
||||
// A senior citizen with ₹8,00,000 of interest could still declare, provided
|
||||
// deductions and rebate left the liability at nil.
|
||||
const r = checkEligibility(oldSenior(), income(800_000, 900_000));
|
||||
expect(r.form).toBe('15H');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects a 15H that leaves a liability', () => {
|
||||
const r = checkEligibility(oldSenior(), income(800_000, 1_500_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('nil');
|
||||
});
|
||||
|
||||
it('cites the rule that applied to that year', () => {
|
||||
const r = checkEligibility(old({ pan: '' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('Rule 29C');
|
||||
});
|
||||
|
||||
it('still checks the interest limb when income is not on record', () => {
|
||||
// Income has to be declared by the payee, but the bank knows the interest
|
||||
// figure, so that limb remains assessable.
|
||||
expect(
|
||||
checkEligibility(old(), { estimatedInterest: 450_000, estimatedIncome: 0, incomeOnRecord: false }).eligible
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('eligibility from FY 2026-27 — the single Form 121 test', () => {
|
||||
it('drops the interest limb entirely', () => {
|
||||
// Under a 15G this failed: interest of ₹4,50,000 was over the basic
|
||||
// exemption limit. Form 121 asks only whether the tax liability is nil, so
|
||||
// the same payee is now eligible.
|
||||
expect(checkEligibility(general({ fy: OLD_FY }), income(450_000, 380_000)).eligible).toBe(false);
|
||||
const r = checkEligibility(general(), income(450_000, 380_000));
|
||||
expect(r.form).toBe('121');
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
|
||||
it('applies one nil-liability ceiling to every age', () => {
|
||||
for (const ctx of [
|
||||
general(),
|
||||
senior()
|
||||
]) {
|
||||
expect(checkEligibility(ctx, income(800_000, 900_000)).eligible).toBe(true);
|
||||
const over = checkEligibility(ctx, income(800_000, 1_500_000));
|
||||
expect(over.eligible).toBe(false);
|
||||
expect(over.reasons.join(' ')).toContain('nil');
|
||||
}
|
||||
});
|
||||
|
||||
it('takes PAN only — Aadhaar is not a substitute', () => {
|
||||
const r = checkEligibility(general({ pan: '' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('Rule 211');
|
||||
expect(r.reasons.join(' ')).toContain('Aadhaar is not a substitute');
|
||||
});
|
||||
|
||||
it('refuses an entity PAN', () => {
|
||||
const r = checkEligibility(general({ pan: 'ABCCM1234E' }), income(10_000, 100_000));
|
||||
expect(r.eligible).toBe(false);
|
||||
expect(r.reasons.join(' ')).toContain('entity');
|
||||
});
|
||||
|
||||
it('refuses a non-resident', () => {
|
||||
expect(checkEligibility(general({ resident: false }), income(10_000, 100_000)).eligible).toBe(false);
|
||||
});
|
||||
|
||||
it('reports the nil-liability test as unassessed rather than passing it silently', () => {
|
||||
// The branch cannot compute income earned outside the bank, so a missing
|
||||
// figure must not read as a pass.
|
||||
const r = checkEligibility(general(), {
|
||||
estimatedInterest: 90_000,
|
||||
estimatedIncome: 0,
|
||||
incomeOnRecord: false
|
||||
});
|
||||
expect(r.unassessed.length).toBe(1);
|
||||
expect(r.unassessed[0]).toContain('not on record');
|
||||
// With no interest limb left, nothing else can fail it — so the branch must
|
||||
// read the unassessed note rather than treat the row as cleared.
|
||||
expect(r.eligible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statutory references travel with the year', () => {
|
||||
it('quotes the 1961 Act for FY 2025-26 and the 2025 Act for FY 2026-27', () => {
|
||||
expect(FY_TAX_CONFIG['2025-26'].regime).toBe('ITA_1961');
|
||||
expect(FY_TAX_CONFIG['2025-26'].interestTdsSection).toContain('194A');
|
||||
expect(FY_TAX_CONFIG['2025-26'].tdsStatementForm).toBe('Form 26Q');
|
||||
expect(FY_TAX_CONFIG['2025-26'].declarationStatementFrequency).toBe('QUARTERLY');
|
||||
|
||||
expect(FY_TAX_CONFIG['2026-27'].regime).toBe('ITA_2025');
|
||||
expect(FY_TAX_CONFIG['2026-27'].interestTdsSection).toContain('393');
|
||||
expect(FY_TAX_CONFIG['2026-27'].tdsStatementForm).toBe('Form 140');
|
||||
expect(FY_TAX_CONFIG['2026-27'].declarationStatementFrequency).toBe('MONTHLY');
|
||||
});
|
||||
|
||||
it('carries the same thresholds and rates across the changeover', () => {
|
||||
const a = FY_TAX_CONFIG['2025-26'];
|
||||
const b = FY_TAX_CONFIG['2026-27'];
|
||||
expect(b.thresholdGeneral).toBe(a.thresholdGeneral);
|
||||
expect(b.thresholdSenior).toBe(a.thresholdSenior);
|
||||
expect(b.rate).toBe(a.rate);
|
||||
expect(b.rateNoPan).toBe(a.rateNoPan);
|
||||
});
|
||||
});
|
||||
|
||||
describe('default interest under section 201(1A)', () => {
|
||||
it('counts any part of a month as a full month', () => {
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-07-07')).toBe(0);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-07-08')).toBe(1);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-08-07')).toBe(1);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2026-08-08')).toBe(2);
|
||||
expect(monthsOrPartBetween('2026-07-07', '2027-01-07')).toBe(6);
|
||||
});
|
||||
|
||||
it('charges 1% a month for deducting late and 1.5% for depositing late', () => {
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEDUCTION', FY)).toEqual({
|
||||
months: 6,
|
||||
rate: 0.01,
|
||||
interest: 360
|
||||
});
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2027-01-07', 'DEPOSIT', FY).interest).toBe(540);
|
||||
});
|
||||
|
||||
it('charges nothing when settled on time', () => {
|
||||
expect(lateDeductionInterest(6_000, '2026-07-07', '2026-07-07', 'DEPOSIT', FY).interest).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('helpers', () => {
|
||||
it('resolves the financial year of a date', () => {
|
||||
expect(fyOf('2026-04-01')).toBe('2026-27');
|
||||
expect(fyOf('2027-03-31')).toBe('2026-27');
|
||||
expect(fyOf('2026-03-31')).toBe('2025-26');
|
||||
expect(fyOf('2027-04-01')).toBe('2027-28');
|
||||
});
|
||||
|
||||
it('buckets dated credits into the right quarters and drops out-of-year dates', () => {
|
||||
const b = bucketByQuarter(
|
||||
[
|
||||
{ date: '2026-04-05', amount: 100 },
|
||||
{ date: '2026-08-01', amount: 200 },
|
||||
{ date: '2026-11-30', amount: 300 },
|
||||
{ date: '2027-01-15', amount: 400 },
|
||||
{ date: '2026-03-31', amount: 999 },
|
||||
{ date: '2027-04-01', amount: 999 }
|
||||
],
|
||||
FY
|
||||
);
|
||||
expect(b).toEqual({ 1: 100, 2: 200, 3: 300, 4: 400 });
|
||||
});
|
||||
|
||||
it('spreads an annual estimate without losing rupees to division', () => {
|
||||
for (const annual of [
|
||||
38_500,
|
||||
52_801,
|
||||
1,
|
||||
999_999
|
||||
]) {
|
||||
expect(projectQuarterlyCredits(annual, FY).reduce((s, x) => s + x.amount, 0)).toBe(annual);
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes the cumulative liability directly for ad-hoc checks', () => {
|
||||
expect(cumulativeLiability(50_000, 50_000, general())).toBe(0);
|
||||
expect(cumulativeLiability(50_001, 50_001, general())).toBe(5_000);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user