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

This commit is contained in:
2026-08-05 17:23:56 +05:30
parent cc5cd4c6ab
commit 569f17ef46
37 changed files with 9800 additions and 738 deletions
@@ -954,6 +954,75 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
Validators.required
]
});
this.linkMonthlyAnnual('monthlyIncome', 'annualIncome');
this.linkMonthlyAnnual('monthlyTurnover', 'annualTurnover');
this.linkIncomeToTurnover();
}
/**
* Keeps a Monthly/Annual pair of amount fields in sync at a 12x ratio — typing into either
* one auto-fills the other. Uses { emitEvent: false } on the write side so the two
* subscriptions don't trigger each other back and forth.
*/
/** Rounds to at most 2 decimals without padding whole numbers with a trailing ".00". */
private round2(n: number): string {
return (Math.round(n * 100) / 100).toString();
}
private linkMonthlyAnnual(monthlyControlName: string, annualControlName: string): void {
const monthly = this.createClientForm.get(monthlyControlName);
const annual = this.createClientForm.get(annualControlName);
if (!monthly || !annual) {
return;
}
monthly.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annual.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 12) : '', { emitEvent: false });
});
annual.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthly.setValue(!isNaN(num) && num >= 0 ? this.round2(num / 12) : '', { emitEvent: false });
});
}
/**
* Auto-calculates turnover based on income (doubling monthly income for monthly turnover,
* and annual income for annual/yearly turnover).
*/
private linkIncomeToTurnover(): void {
const monthlyIncome = this.createClientForm.get('monthlyIncome');
const monthlyTurnover = this.createClientForm.get('monthlyTurnover');
const annualIncome = this.createClientForm.get('annualIncome');
const annualTurnover = this.createClientForm.get('annualTurnover');
if (monthlyIncome && monthlyTurnover) {
monthlyIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
monthlyTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
if (annualIncome && annualTurnover) {
annualIncome.valueChanges.subscribe((value) => {
if (this.suppressMonthlyAnnualLink) {
return;
}
const num = parseFloat(value);
annualTurnover.setValue(!isNaN(num) && num >= 0 ? this.round2(num * 2) : '', { emitEvent: true });
});
}
}
/**
@@ -723,6 +723,33 @@
<span>PAN verification requires client to be created first. Complete General tab and click Next to save.</span>
</div>
}
<!-- KYC Exception Flag -->
<div class="exception-flag-box" style="margin-top: 20px; padding: 16px; border: 1px solid #f59e0b; background: #fffbeb; border-radius: 8px; clear: both;">
<h5 style="margin: 0 0 12px 0; font-size: 14px; font-weight: 600; color: #b45309; display: flex; align-items: center; gap: 8px;">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exception Flag
</h5>
<div style="display: flex; align-items: center; gap: 20px; flex-wrap: wrap;">
<mat-checkbox formControlName="isException">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 280px; margin-bottom: -1.25em;">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
</div>
</div>
<!-- ═══ SECTION: KYC Document Management ═══ -->
@@ -1410,6 +1437,7 @@
</div>
</div>
<!-- ═══ SECTION: Photograph & Signature ═══ -->
<div class="section-block">
<h4 class="section-title">
@@ -29,6 +29,7 @@ import { ClientsService } from '../../clients.service';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
import { ProofType, normalizeProofType, groupProofsByCategory } from '../../shared/kyc-proof-types';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { KycUploadHelperService } from '../../shared/kyc-upload-helper.service';
import { CaptureImageDialogComponent } from '../../clients-view/custom-dialogs/capture-image-dialog/capture-image-dialog.component';
import { DrawSignatureDialogComponent } from '../../clients-view/custom-dialogs/draw-signature-dialog/draw-signature-dialog.component';
@@ -274,6 +275,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
riskCalculationReason = '';
showRiskRulesTable = false;
/**
* Exception state as last read back from the server. Needed to tell "this client never had an
* exception" from "this client had one and has now cleared it" — only the latter should be
* stamped resolved and surface in the Resolved tab.
*/
persistedIsException = false;
persistedExceptionReason: string | null = null;
/** PAN verification */
panVerifyStatus: 'NONE' | 'VERIFIED' | 'FAILED' = 'NONE';
panVerifying = false;
@@ -468,6 +477,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
allowRiskOverride: [false],
allowAmlOverride: [false],
remarks: [''],
isException: [false],
exceptionReason: [[]],
amlCheckStatus: [
'CLEAR',
Validators.required
@@ -610,8 +621,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
dormantFlag: 'ACTIVE',
accountStatus: 'ACTIVE',
form60Type: 'FORM_60',
form15Type: '15G'
form15Type: '15G',
isException: false,
exceptionReason: []
});
this.persistedIsException = false;
this.persistedExceptionReason = null;
this.kycId = '';
this.kycLoaded = false;
this.kycStatus = 0;
@@ -942,8 +957,12 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form60Date: parseDate(data.form60Date),
form15Applicable: data.form15Applicable || false,
form15Type: data.form15Type || '15G',
form15FY: data.form15FY || ''
form15FY: data.form15FY || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
this.persistedIsException = data.isException === true;
this.persistedExceptionReason = data.exceptionReason || null;
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.kycDocProofs.find((p) => p.name === data.kycDocumentName) || null;
@@ -1843,7 +1862,21 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
form15Applicable: v.form15Applicable || false,
form15Type: v.form15Type || null,
form15FY: v.form15FY || null,
remarks: v.remarks || null
remarks: v.remarks || null,
// Exception state is DERIVED from verification status, not from a checkbox — an unverified
// or blank PAN/Aadhaar is an exception by definition, so the officer cannot forget to flag
// it. See deriveKycExceptionFields() for the raise / narrow / close rules.
...deriveKycExceptionFields({
panVerified: this.isNonIndividual ? this.entPanVerifyStatus === 'VERIFIED' : this.panVerifyStatus === 'VERIFIED',
aadhaarVerified: this.aadhaarVerifyStatus === 'VERIFIED',
// Non-individual clients submit a dummy masked Aadhaar, so checking it would saddle every
// company with a permanent exception.
aadhaarApplicable: !this.isNonIndividual,
hadException: this.persistedIsException,
previousReason: this.persistedExceptionReason,
manualReasons: Array.isArray(v.exceptionReason) ? v.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), dateFormat)
})
};
}
@@ -1856,7 +1889,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
v.enterprisePan ||
v.cinNumber ||
v.kycDocumentName ||
v.form15Applicable
v.form15Applicable ||
v.isException
);
}
@@ -60,6 +60,7 @@ import { ClientCollateralResolver } from './common-resolvers/client-collateral.r
import { KycTabComponent } from './clients-view/kyc-tab/kyc-tab.component';
import { CkycTabComponent } from './clients-view/ckyc-tab/ckyc-tab.component';
import { Form60TabComponent } from './clients-view/form60-tab/form60-tab.component';
import { Form121TabComponent } from './clients-view/form121-tab/form121-tab.component';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -193,6 +194,11 @@ const routes: Routes = [
component: Form60TabComponent,
data: { title: 'Form 97', breadcrumb: 'Form 97', routeParamBreadcrumb: false }
},
{
path: 'form121',
component: Form121TabComponent,
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
path: 'documents',
component: DocumentsTabComponent,
@@ -545,6 +545,15 @@
>
<i class="fa fa-file-text cv-tab-icon"></i> {{ 'labels.inputs.Form 60' | translate }}
</a>
<a
mat-tab-link
[routerLink]="['./form121']"
routerLinkActive
#form121="routerLinkActive"
[active]="form121.isActive"
>
<i class="fa fa-percent cv-tab-icon"></i> Form 121 / TDS
</a>
<a
mat-tab-link
[routerLink]="['./documents']"
@@ -0,0 +1,428 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="f121">
@if (loading) {
<div class="f121__loading">
<mat-spinner [diameter]="36"></mat-spinner>
<span>Reading the customer's deposit ledger…</span>
</div>
} @else if (loadError) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>Could not load the customer's accounts. Try again, or check the deposit service.</div>
</div>
} @else {
<!-- ── Header ── -->
<div class="f121__head">
<div>
<h3 class="f121__title">Form 121 &amp; TDS on deposit interest</h3>
<div class="f121__sub">
Tax deducted at source under {{ statute.section }} on fixed and recurring deposit interest.
</div>
</div>
<div class="f121__controls">
@if (availableFys.length) {
<label class="f121__field">
<span>Financial year</span>
<select [(ngModel)]="fy" (ngModelChange)="recompute()">
@for (year of availableFys; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
}
<div class="f121__field">
<span>Settlement</span>
<div
class="f121__mode"
[matTooltip]="'Set by the deposit product\'s interest-posting frequency: tax falls due when interest is credited, so the branch cannot defer it. Not a customer preference.'"
>
<mat-icon>schedule</mat-icon>
{{ mode === 'QUARTERLY' ? 'Quarterly' : 'At year end' }}
</div>
</div>
</div>
</div>
@if (!schedule) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>
@if (!dateOfBirth) {
Date of birth is not on record, so the senior-citizen threshold cannot be determined. Add it on the Personal
Data tab.
} @else {
No interest has been credited to this customer's deposits yet.
}
</div>
</div>
} @else {
<!-- ── KPI strip ── -->
<div class="f121__kpis">
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.totalInterest | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Interest earned (FY {{ schedule.fy }})</div>
</div>
<div class="f121__kpi" [class.f121__kpi--warn]="actualTds > 0">
<div class="f121__kpi-n">₹{{ actualTds | number: '1.0-0' }}</div>
<div class="f121__kpi-l">TDS deducted</div>
</div>
<div class="f121__kpi">
<div class="f121__kpi-n">₹{{ schedule.threshold | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Threshold ({{ schedule.senior ? 'senior citizen' : 'general' }})</div>
</div>
@if (thresholdCrossed) {
<div class="f121__kpi f121__kpi--warn">
<div class="f121__kpi-n">Crossed</div>
<div class="f121__kpi-l">TDS applies this year</div>
</div>
} @else {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ headroom | number: '1.0-0' }}</div>
<div class="f121__kpi-l">More interest before TDS starts</div>
</div>
}
@if (tdsSaved > 0) {
<div class="f121__kpi f121__kpi--ok">
<div class="f121__kpi-n">₹{{ tdsSaved | number: '1.0-0' }}</div>
<div class="f121__kpi-l">Saved by Form 121</div>
</div>
}
</div>
<!-- ── Alerts ── -->
@if (variance !== 0) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
<strong>Ledger and computed tax differ by ₹{{ variance | number: '1.0-0' }}.</strong>
The ledger shows ₹{{ actualTds | number: '1.0-0' }} withheld; {{ statute.section }} on this year's interest
comes to ₹{{ schedule.totalTds | number: '1.0-0' }}. Investigate before the quarterly return is filed.
</div>
</div>
}
@if (declarationNotAppliedToAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
A Form 121 declaration is on record for this year, but withholding is still switched on for one or more
deposits. Turn it off on those accounts, or tax will keep being deducted.
</div>
</div>
}
@if (declarationUselessOnJointAccounts) {
<div class="f121__note f121__note--warn">
<mat-icon>warning_amber</mat-icon>
<div>
This customer has filed Form 121, but tax on
{{ jointHolderAccounts.length }} joint deposit{{ jointHolderAccounts.length === 1 ? '' : 's' }} is deducted
against the first-named holder. Their declaration does not cover those — the primary holder must file their
own.
</div>
</div>
}
@if (shouldSuggestDeclaration) {
<div class="f121__note f121__note--tip">
<mat-icon>lightbulb_outline</mat-icon>
<div>
₹{{ schedule.totalTds | number: '1.0-0' }} of tax falls due this year. If the customer's total tax liability
for the year is nil, a {{ formLabel }} declaration would stop it being deducted at all — better than
reclaiming it later as a refund.
</div>
</div>
}
@if (!panWellFormed) {
<div class="f121__note f121__note--warn">
<mat-icon>error_outline</mat-icon>
<div>
No valid PAN on record, so tax is deducted at the higher section 206AA rate of
{{ schedule.rateApplied * 100 | number: '1.0-0' }}%. A Form 121 declaration is void without a valid PAN.
</div>
</div>
}
@for (note of schedule.notes; track note) {
<div class="f121__note">
<mat-icon>info_outline</mat-icon>
<div>{{ note }}</div>
</div>
}
<!-- ── Declaration panel ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>assignment_turned_in</mat-icon>
<span>Declaration</span>
</div>
<div class="f121__kv">
<div class="f121__kv-row">
<span>Status</span>
<span>
@if (declarationOnRecord) {
<span class="f121__pill f121__pill--ok">On record — {{ formLabel }}</span>
} @else {
<span class="f121__pill">Not filed</span>
}
</span>
</div>
<div class="f121__kv-row">
<span>Form &amp; threshold band</span>
<span>{{ formLabel }} · {{ bandLabel }} band · {{ age }} yrs in this FY</span>
</div>
<div class="f121__kv-row">
<span>PAN</span>
<span class="f121__mono">
{{ pan || '—' }}
@if (pan && !panWellFormed) {
<span class="f121__pill f121__pill--warn">invalid</span>
}
</span>
</div>
@if (declarationOnRecord && declarationFy) {
<div class="f121__kv-row">
<span>Declared for</span>
<span>FY {{ declarationFy }}</span>
</div>
}
@if (declarationOnRecord && !acceptanceDateKnown) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Acceptance date</span>
<span>
Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
the figures below.
</span>
</div>
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
<div class="f121__kv-row f121__kv-row--warn">
<span>Eligibility</span>
<span>{{ reason }}</span>
</div>
}
@for (note of eligibility.unassessed; track note) {
<div class="f121__kv-row f121__kv-row--muted">
<span>Not assessed</span>
<span>{{ note }}</span>
</div>
}
}
</div>
</div>
<!-- ── Quarter-wise ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>table_chart</mat-icon>
<span>Quarter by quarter</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<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 schedule.lines; track trackLine($index, line)) {
<tr [class.f121__row--warn]="line.thresholdCrossedHere">
<td>
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="f121__pill f121__pill--warn">threshold crossed</span>
}
@if (line.declarationActive) {
<span class="f121__pill f121__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="muted">{{ line.dueDate }}</td>
</tr>
}
<tr class="f121__row--total">
<td><strong>Total</strong></td>
<td class="num">
<strong>₹{{ schedule.totalInterest | number: '1.0-0' }}</strong>
</td>
<td></td>
<td></td>
<td class="num">
<strong>₹{{ schedule.totalTds | number: '1.0-0' }}</strong>
</td>
<td></td>
</tr>
</tbody>
</table>
</div>
@if (schedule.shieldedInterest > 0) {
<div class="f121__foot">
₹{{ schedule.shieldedInterest | number: '1.0-0' }} was paid without deduction on the strength of the
declaration. This amount is still reportable in the quarterly {{ statute.statement }} return.
</div>
}
</div>
<!-- ── Section 1: accounts that count ── -->
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>account_balance</mat-icon>
<span>Deposits counted towards this customer's TDS</span>
</div>
@if (liableAccounts.length) {
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th class="num">TDS deducted</th>
<th>Withholding</th>
<th>Settles</th>
</tr>
</thead>
<tbody>
@for (a of liableAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ a.tdsDeducted | number: '1.0-0' }}</td>
<td>
@if (a.withHoldTax) {
<span class="f121__pill f121__pill--warn">On</span>
@if (a.taxGroup) {
<div class="muted">{{ a.taxGroup }}</div>
}
} @else {
<span class="f121__pill">Off</span>
}
</td>
<td>{{ a.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Aggregated across every branch — a bank on core banking computes the deduction threshold for the bank as a
whole, not branch by branch.
</div>
} @else {
<div class="f121__empty">No fixed or recurring deposits where this customer is the deductee.</div>
}
</div>
<!-- ── Section 2: joint holdings ── -->
@if (jointHolderAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>group</mat-icon>
<span>Deposits where this customer is a joint holder</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>Deducted against</th>
</tr>
</thead>
<tbody>
@for (a of jointHolderAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td class="muted">
First-named holder
@if (a.deducteeClientId) {
(client {{ a.deducteeClientId }})
}
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
Tax on a joint deposit is deducted against the first-named holder's PAN, so this interest is
<strong>not</strong> counted in the figures above and does not use up this customer's threshold.
</div>
</div>
}
<!-- ── Section 3: exempt accounts ── -->
@if (exemptAccounts.length) {
<div class="f121__card">
<div class="f121__card-head">
<mat-icon>savings</mat-icon>
<span>Savings and current accounts</span>
</div>
<div class="f121__table-wrap">
<table class="f121__table">
<thead>
<tr>
<th>Account</th>
<th>Type</th>
<th class="num">Interest (FY)</th>
<th>TDS</th>
</tr>
</thead>
<tbody>
@for (a of exemptAccounts; track trackAccount($index, a)) {
<tr>
<td>
<div class="f121__mono">{{ a.accountNo }}</div>
<div class="muted">{{ a.productName }}</div>
</td>
<td>{{ a.kind }}</td>
<td class="num">₹{{ a.interest | number: '1.0-0' }}</td>
<td><span class="f121__pill">Not applicable</span></td>
</tr>
}
</tbody>
</table>
</div>
<div class="f121__foot">
The charge applies to time deposits. Savings bank interest is outside it and is excluded from the threshold
— the customer claims the 80TTA or 80TTB deduction on it in their own return instead.
</div>
</div>
}
}
}
</div>
@@ -0,0 +1,334 @@
/**
* 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/.
*/
.f121 {
--f121-line: #e5e7eb;
--f121-bg: #f9fafb;
--f121-ink: #111827;
--f121-muted: #6b7280;
--f121-blue: #1d4ed8;
--f121-green: #15803d;
--f121-amber: #b45309;
padding: 16px;
font-size: 13px;
color: var(--f121-ink);
}
.f121__loading {
display: flex;
align-items: center;
gap: 12px;
padding: 40px 16px;
color: var(--f121-muted);
}
/* ── Header ── */
.f121__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-end;
justify-content: space-between;
margin-bottom: 16px;
}
.f121__title {
margin: 0;
font-size: 16px;
font-weight: 700;
}
.f121__sub {
margin-top: 4px;
color: var(--f121-muted);
font-size: 12px;
}
.f121__controls {
display: flex;
gap: 16px;
align-items: flex-end;
}
.f121__field {
display: flex;
flex-direction: column;
gap: 4px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--f121-muted);
}
select {
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
}
.f121__mode {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 10px;
border: 1px solid var(--f121-line);
border-radius: 6px;
background: var(--f121-bg);
font-weight: 600;
cursor: help;
mat-icon {
font-size: 16px;
width: 16px;
height: 16px;
color: var(--f121-muted);
}
}
/* ── KPI strip ── */
.f121__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.f121__kpi {
padding: 12px 14px;
border: 1px solid var(--f121-line);
border-left: 3px solid var(--f121-blue);
border-radius: 8px;
background: #fff;
&--ok {
border-left-color: var(--f121-green);
}
&--warn {
border-left-color: var(--f121-amber);
}
}
.f121__kpi-n {
font-size: 19px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.f121__kpi-l {
margin-top: 2px;
font-size: 11px;
color: var(--f121-muted);
line-height: 1.35;
}
/* ── Notes ── */
.f121__note {
display: flex;
gap: 10px;
align-items: flex-start;
margin-bottom: 12px;
padding: 11px 13px;
border-radius: 8px;
border-left: 3px solid var(--f121-blue);
background: color-mix(in srgb, var(--f121-blue) 6%, transparent);
line-height: 1.5;
mat-icon {
flex-shrink: 0;
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-blue);
}
&--warn {
border-left-color: var(--f121-amber);
background: color-mix(in srgb, var(--f121-amber) 8%, transparent);
mat-icon {
color: var(--f121-amber);
}
}
&--tip {
border-left-color: var(--f121-green);
background: color-mix(in srgb, var(--f121-green) 7%, transparent);
mat-icon {
color: var(--f121-green);
}
}
}
/* ── Cards ── */
.f121__card {
margin-bottom: 16px;
border: 1px solid var(--f121-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.f121__card-head {
display: flex;
align-items: center;
gap: 8px;
padding: 11px 14px;
background: var(--f121-bg);
border-bottom: 1px solid var(--f121-line);
font-weight: 700;
font-size: 12.5px;
mat-icon {
font-size: 18px;
width: 18px;
height: 18px;
color: var(--f121-muted);
}
}
.f121__foot {
padding: 10px 14px;
border-top: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 11.5px;
line-height: 1.5;
}
.f121__empty {
padding: 20px 14px;
color: var(--f121-muted);
text-align: center;
}
/* ── Key/value list ── */
.f121__kv-row {
display: grid;
grid-template-columns: 190px 1fr;
gap: 12px;
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
> span:first-child {
color: var(--f121-muted);
font-weight: 600;
}
&--muted > span:last-child {
color: var(--f121-muted);
font-size: 12px;
}
&--warn > span:last-child {
color: var(--f121-amber);
}
}
/* ── Tables ── */
.f121__table-wrap {
overflow-x: auto;
}
.f121__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 10px 14px;
background: #fff;
border-bottom: 1px solid var(--f121-line);
color: var(--f121-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 10px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.muted {
color: var(--f121-muted);
font-size: 11.5px;
}
}
.f121__row--warn > td {
background: color-mix(in srgb, var(--f121-amber) 7%, transparent);
}
.f121__row--total > td {
background: var(--f121-bg);
border-top: 1px solid var(--f121-line);
}
/* ── Bits ── */
.f121__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--f121-line);
background: var(--f121-bg);
color: var(--f121-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--f121-green) 35%, transparent);
background: color-mix(in srgb, var(--f121-green) 10%, transparent);
color: var(--f121-green);
}
&--warn {
border-color: color-mix(in srgb, var(--f121-amber) 35%, transparent);
background: color-mix(in srgb, var(--f121-amber) 12%, transparent);
color: var(--f121-amber);
}
}
.f121__mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
@@ -0,0 +1,500 @@
/**
* 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/.
*/
/** Angular Imports */
import { Component, OnDestroy, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { ActivatedRoute } from '@angular/router';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { MatTooltip } from '@angular/material/tooltip';
import { Observable, Subject, forkJoin, of } from 'rxjs';
import { catchError, switchMap, takeUntil } from 'rxjs/operators';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/** Domain logic */
import {
Declaration,
DeclarationForm,
EligibilityResult,
FY_TAX_CONFIG,
InterestCredit,
TdsAccountInput,
TdsMode,
TdsQuarterLine,
TdsSchedule,
ageInFy,
buildSchedule,
checkEligibility,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isTdsApplicable,
isWellFormedPan,
normaliseFy,
tdsDeducteeOf,
tdsModeFor
} from 'app/letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** One of the customer's interest-bearing accounts, with its FY figures. */
export interface Form121AccountRow {
accountNo: string;
productName: string;
/** 'Fixed Deposit', 'Recurring Deposit' or 'Savings'. */
kind: string;
/** Interest credited to this account inside the selected FY. */
interest: number;
/** Tax Fineract has actually withheld on this account inside the FY. */
tdsDeducted: number;
/** Whether Fineract is currently withholding tax on this account. */
withHoldTax: boolean;
taxGroup: string | null;
/** When tax on this account settles — from the product, not a preference. */
mode: TdsMode;
/** Interest here attracts TDS at all (a time deposit, not a savings account). */
tdsApplicable: boolean;
/** This customer is the deductee — false when they are a joint holder only. */
isDeductee: boolean;
/** Who the tax is deducted against, when it is not this customer. */
deducteeClientId: number | null;
}
/** 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-form121-tab',
templateUrl: './form121-tab.component.html',
styleUrls: ['./form121-tab.component.scss'],
standalone: true,
imports: [
...STANDALONE_SHARED_IMPORTS,
CommonModule,
FormsModule,
MatIcon,
MatProgressSpinner,
MatTooltip
]
})
export class Form121TabComponent implements OnInit, OnDestroy {
private route = inject(ActivatedRoute);
private clientsService = inject(ClientsService);
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
private destroy$ = new Subject<void>();
clientId = '';
loading = true;
loadError = false;
// ── Customer ──
clientName = '';
dateOfBirth = '';
pan = '';
panWellFormed = false;
// ── Declaration on record ──
declarationOnRecord = false;
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
/** Kind recorded at KYC, meaningful only for a pre-2026 year. */
declarationKind: '15G' | '15H' = '15G';
declarationFy = '';
/**
* The KYC step records only the year, not the date the branch accepted the
* declaration, so it has to be treated as covering the whole year. Surfaced on
* screen rather than hidden — a mid-year acceptance would change the figures.
*/
acceptanceDateKnown = false;
eligibility: EligibilityResult | null = null;
// ── Selection ──
availableFys: string[] = [];
fy = '';
// ── Computed ──
accounts: Form121AccountRow[] = [];
schedule: TdsSchedule | null = null;
actualTds = 0;
tdsSaved = 0;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
/** Withheld-tax postings by FY, from the ledger. */
private actualTdsByFy = new Map<string, number>();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
this.clientId = params['clientId'];
this.load();
});
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ────────────────────────────── Loading ──────────────────────────────
private load(): void {
this.loading = true;
this.loadError = false;
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
})
.pipe(
takeUntil(this.destroy$),
switchMap(({ client, kyc, accounts }) => {
this.applyClient(client);
this.applyKyc(kyc);
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
catchError(() => {
this.loadError = true;
return of([] as any[]);
})
)
.subscribe((details: any[]) => {
this.loading = false;
this.ingest(details.filter(Boolean));
this.recompute();
});
}
private applyClient(client: any): void {
this.clientName = client?.displayName ?? '';
this.dateOfBirth = toIso(client?.dateOfBirth) ?? '';
}
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
this.declarationOnRecord = !!kyc?.form15Applicable;
this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm = this.fy ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
this.declarationFy = kyc?.form15FY ?? '';
this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
private depositAccountsOf(accounts: any): any[] {
return (accounts?.savingsAccounts ?? []).filter(
(a: any) => a.status?.active || a.status?.matured || a.status?.closed
);
}
private fetchAccount(a: any): Observable<any> {
const id = `${a.id}`;
const code = a.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);
}
// One unreadable account must not blank the whole tab.
return call.pipe(catchError(() => of(null)));
}
/**
* Turns each account payload into an engine input.
*
* Joint and minor attributes are read defensively: they are captured on the
* account stepper but may not surface on every read endpoint, and an absent
* field must default to a single-name account rather than throw.
*/
private ingest(details: any[]): void {
this.accountInputs = [];
this.actualTdsByFy = new Map<string, number>();
const fys = new Set<string>();
const clientIdNum = Number(this.clientId);
for (const d of details) {
const code: string = d.depositType?.code ?? '';
const extra = d.extraAttributes ?? d.externalAttributes ?? d;
const ref = {
id: d.id,
accountNo: d.accountNo ?? `${d.id}`,
depositTypeCode: code,
accountStatus: extra.accountStatus ?? d.accountStatus ?? 'SINGLE',
clientId: d.clientId ?? clientIdNum,
primaryClientId: extra.primaryClientId ?? d.primaryClientId ?? null,
guardianClientId: extra.guardianClientId ?? d.guardianClientId ?? null,
jointHolders: extra.jointHolders ?? d.jointHolders ?? [],
interestPostingPeriodType: d.interestPostingPeriodType ?? null
};
const credits: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
if (t.reversed || t.isReversal) {
continue;
}
const iso = toIso(t.date);
if (!iso) {
continue;
}
fys.add(fyOf(iso));
if (t.transactionType?.interestPosting) {
credits.push({ date: iso, amount: t.amount ?? 0, accountNo: ref.accountNo });
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
const txnFy = fyOf(iso);
this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
}
}
const deductee = tdsDeducteeOf(ref);
const meta: Form121AccountRow = {
accountNo: ref.accountNo,
productName: d.savingsProductName ?? d.depositProductName ?? d.productName ?? '—',
kind: code.includes('fixedDeposit')
? 'Fixed Deposit'
: code.includes('recurringDeposit')
? 'Recurring Deposit'
: 'Savings',
interest: 0,
tdsDeducted,
withHoldTax: !!d.withHoldTax,
taxGroup: d.taxGroup?.name ?? null,
mode: tdsModeFor(ref),
tdsApplicable: isTdsApplicable(ref),
isDeductee: deductee === clientIdNum,
deducteeClientId: deductee
};
this.accountInputs.push({ ...ref, credits, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
}
}
// ───────────────────────────── Computation ─────────────────────────────
/** Re-runs the engine for the selected year. */
recompute(): void {
this.schedule = null;
this.eligibility = null;
this.tdsSaved = 0;
this.accounts = this.accountInputs.map((a) => a.meta);
if (!this.fy) {
return;
}
const year = normaliseFy(this.fy);
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
}
this.actualTds = this.actualTdsByFy.get(year) ?? 0;
if (!this.dateOfBirth) {
return;
}
// The one place rules 8 to 12 are applied: savings interest dropped, joint
// deposits attributed to the primary holder, every branch aggregated.
const credits = collectTdsCredits(Number(this.clientId), this.accountInputs, year);
const ctx = { fy: year, dateOfBirth: this.dateOfBirth, pan: this.pan, resident: true };
const declaration = this.declarationForSelectedFy();
this.schedule = buildSchedule(this.overallMode, credits, { ...ctx, declaration });
if (declaration) {
const without = buildSchedule(this.overallMode, credits, { ...ctx, declaration: null });
this.tdsSaved = Math.max(0, without.totalTds - this.schedule.totalTds);
}
// The branch cannot compute income earned outside the bank, so the income
// limb is reported as unassessed rather than silently passed.
this.eligibility = checkEligibility(ctx, {
estimatedInterest: this.schedule.totalInterest,
estimatedIncome: 0,
incomeOnRecord: false
});
}
/**
* When tax settles for this customer overall.
*
* Timing is per account, but the customer needs one view. A quarterly account
* anywhere in the book means a deduction falls due at the first quarter end, so
* that is the honest headline; the per-account list carries the detail.
*/
private get overallMode(): TdsMode {
const applicable = this.accountInputs.filter((a) => a.meta.tdsApplicable && a.meta.isDeductee);
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
/** The declaration, but only when it was filed for the year being shown. */
private declarationForSelectedFy(): Declaration | null {
if (!this.declarationOnRecord || !this.fy) {
return null;
}
const year = normaliseFy(this.fy);
if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
return null;
}
return {
kind: this.declarationKind,
// No acceptance date on record — treated as covering the whole year.
acceptedOn: `${year.slice(0, 4)}-04-01`,
fy: year
};
}
// ───────────────────────── Template helpers ─────────────────────────
get mode(): TdsMode {
return this.schedule?.mode ?? 'QUARTERLY';
}
get age(): number {
return this.dateOfBirth && this.fy ? ageInFy(this.dateOfBirth, this.fy) : 0;
}
get formKind(): string {
return this.dateOfBirth && this.fy ? formKindFor(this.dateOfBirth, this.fy) : '—';
}
/** The form the payee would file for the selected year. */
get formLabel(): string {
if (!this.fy) {
return '—';
}
const form = declarationFormFor(this.fy, this.dateOfBirth);
return form === '121' ? 'Form 121' : `Form ${form}`;
}
/** Which deduction trigger applies, now that the forms no longer split by age. */
get bandLabel(): string {
return this.schedule?.senior ? 'Senior citizen' : 'General';
}
/**
* Provision and return references for the selected year. Both changed on
* 01 April 2026, so the template reads them rather than hard-coding them.
*/
get statute(): { section: string; statement: string; rule: string; frequency: string } {
const cfg = this.fy ? FY_TAX_CONFIG[normaliseFy(this.fy)] : undefined;
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
rule: cfg?.declarationRule ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
/** Accounts whose interest counts towards this customer's deduction aggregate. */
get liableAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && a.isDeductee);
}
/** Deposits where this customer is a joint holder but not the deductee. */
get jointHolderAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => a.tdsApplicable && !a.isDeductee);
}
/** Savings and current accounts — interest outside the charge on time deposits. */
get exemptAccounts(): Form121AccountRow[] {
return this.accounts.filter((a) => !a.tdsApplicable);
}
/** Engine figure less what the ledger shows was actually withheld. */
get variance(): number {
return (this.schedule?.totalTds ?? 0) - this.actualTds;
}
/** How much more interest before TDS starts. */
get headroom(): number {
if (!this.schedule) {
return 0;
}
return Math.max(0, this.schedule.threshold - this.schedule.totalInterest);
}
get thresholdCrossed(): boolean {
return !!this.schedule && this.schedule.totalInterest > this.schedule.threshold;
}
/**
* A declaration is on record but deposit accounts are still set to withhold —
* the branch has not switched them off.
*/
get declarationNotAppliedToAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
}
/**
* The customer filed a declaration but holds joint deposits where someone else
* is the deductee — their form does nothing for those.
*/
get declarationUselessOnJointAccounts(): boolean {
return !!this.declarationForSelectedFy() && this.jointHolderAccounts.length > 0;
}
/** Worth prompting the branch to collect a declaration. */
get shouldSuggestDeclaration(): boolean {
return !this.declarationForSelectedFy() && (this.schedule?.totalTds ?? 0) > 0;
}
trackLine(_: number, line: TdsQuarterLine): number {
return line.quarter;
}
trackAccount(_: number, account: Form121AccountRow): string {
return account.accountNo;
}
}
@@ -42,6 +42,19 @@
<!-- KYC Summary (read-only when not in edit mode) -->
@if (!isEditMode && kycData) {
@if (kycData.isException && !kycData.exceptionResolved) {
<div class="kyc-exception-banner" style="background: #fef3c7; border: 1px solid #f59e0b; border-radius: 8px; padding: 16px; margin-bottom: 20px; display: flex; align-items: flex-start; gap: 12px;">
<mat-icon style="color: #d97706; margin-top: 2px;">warning</mat-icon>
<div>
<strong style="color: #92400e; font-size: 14px;">KYC Exception Active</strong>
<p style="color: #b45309; font-size: 13px; margin: 4px 0 0 0;">
Account is open under a temporary KYC Exception. Reason: <strong>{{ kycData.exceptionReason || 'Other' }}</strong>.
Please contact the customer and complete verification as soon as possible.
</p>
</div>
</div>
}
<!-- Verification Table -->
<div class="kv-table">
<div class="kv-table__header">
@@ -740,11 +753,33 @@
</mat-select>
</mat-form-field>
<!-- KYC Exception Flag -->
<div class="full-span exception-controls-row" style="display: flex; align-items: center; gap: 20px; margin-bottom: 12px; flex-wrap: wrap;">
<mat-checkbox formControlName="isException" style="margin-top: 8px;">Mark as KYC Exception</mat-checkbox>
@if (kycForm.get('isException')?.value) {
<mat-form-field appearance="outline" style="flex: 1; min-width: 250px;">
<mat-label>Exception Reasons</mat-label>
<mat-select formControlName="exceptionReason" multiple>
@if (!isNonIndividual) {
<mat-option value="PAN Card Verification Failed">PAN Card Verification Failed</mat-option>
<mat-option value="Aadhaar Verification Failed">Aadhaar Verification Failed</mat-option>
} @else {
<mat-option value="Enterprise PAN Verification Failed">Enterprise PAN Verification Failed</mat-option>
<mat-option value="GSTIN Verification Failed">GSTIN Verification Failed</mat-option>
<mat-option value="CIN Verification Failed">CIN Verification Failed</mat-option>
}
</mat-select>
</mat-form-field>
}
</div>
<!-- Remarks -->
<mat-form-field appearance="outline" class="full-span">
<mat-label>{{ 'labels.inputs.Remarks' | translate }}</mat-label>
<textarea matInput formControlName="remarks" rows="3"></textarea>
</mat-form-field>
</div>
@if (errorMessage) {
@@ -14,6 +14,7 @@ import { catchError, of, Subject, takeUntil } from 'rxjs';
/** Custom Services */
import { ClientsService } from '../../clients.service';
import { deriveKycExceptionFields } from '../../kyc-exception.util';
import { Dates } from 'app/core/utils/dates';
import { jsPDF } from 'jspdf';
import { MatIcon } from '@angular/material/icon';
@@ -121,7 +122,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
'',
[Validators.pattern(/^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z]{1}[1-9A-Z]{1}Z[0-9A-Z]{1}$/)]
],
remarks: ['']
remarks: [''],
isException: [false],
exceptionReason: [[]]
});
ngOnInit(): void {
@@ -157,7 +160,9 @@ export class KycTabComponent implements OnInit, OnDestroy {
// the full 12-digit number to re-verify, so don't pre-fill.
aadhaarNumber: '',
gstinNumber: data.gstinNumber || '',
remarks: data.remarks || ''
remarks: data.remarks || '',
isException: data.isException || false,
exceptionReason: data.exceptionReason ? data.exceptionReason.split(', ') : []
});
}
});
@@ -195,6 +200,28 @@ export class KycTabComponent implements OnInit, OnDestroy {
}
}
/**
* Exception columns derived from verification status — see `deriveKycExceptionFields()`.
*
* A field counts as verified if it was verified in this session OR is already verified on the
* stored record; either way the customer has cleared that check. Verifying one of two
* outstanding items narrows the exception to the remaining one rather than closing it.
*/
private buildExceptionFields() {
const panVerified = this.verificationStatus.pan === 'VERIFIED' || this.kycData?.panVerified === true;
const aadhaarVerified = this.verificationStatus.aadhaar === 'VERIFIED' || this.kycData?.aadhaarVerified === true;
return deriveKycExceptionFields({
panVerified,
aadhaarVerified,
aadhaarApplicable: !this.isNonIndividual,
hadException: this.kycData?.isException === true,
previousReason: this.kycData?.exceptionReason || null,
manualReasons: Array.isArray(this.kycForm.value.exceptionReason) ? this.kycForm.value.exceptionReason : [],
todayFormatted: this.dateUtils.formatDate(new Date(), 'dd MMMM yyyy')
});
}
saveKyc(): void {
if (this.kycForm.invalid) {
return;
@@ -210,7 +237,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null
remarks: this.kycForm.value.remarks || null,
// dateFormat is required so the backend can parse exceptionResolvedOn when an exception closes.
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -244,6 +274,37 @@ export class KycTabComponent implements OnInit, OnDestroy {
});
}
private saveKycBeforeVerify(callback: () => void, field: 'pan' | 'aadhaar' | 'gstin'): void {
if (this.isEditMode) {
const aadhaarRaw = this.kycForm.value.aadhaarNumber || '';
const aadhaarMasked = aadhaarRaw.length === 12 ? 'XXXXXXXX' + aadhaarRaw.slice(-4) : aadhaarRaw || null;
const payload: any = {
locale: 'en',
panNumber: this.kycForm.value.panNumber || null,
aadhaarNumberMasked: aadhaarMasked,
gstinNumber: this.kycForm.value.gstinNumber || null,
remarks: this.kycForm.value.remarks || null,
riskCategory: this.kycForm.value.riskCategory || 1,
dateFormat: 'dd MMMM yyyy',
...this.buildExceptionFields()
};
this.clientsService.updateClientKyc(this.clientId, this.kycId, payload).subscribe({
next: () => {
this.loadKycData();
callback();
},
error: (err: any) => {
this.verifying[field] = false;
this.verificationStatus[field] = 'FAILED';
this.verificationError[field] = err?.error?.defaultUserMessage || `Failed to update ${field.toUpperCase()} number before verification.`;
}
});
} else {
callback();
}
}
verifyPAN(): void {
if (!this.kycId) {
this.verificationError.pan = 'KYC record not found. Save KYC details first.';
@@ -254,31 +315,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.pan = null;
this.verificationDetails.pan = null;
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyPAN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.pan = false;
if (response?.changes?.panVerified) {
this.verificationStatus.pan = 'VERIFIED';
this.verificationDetails.pan = response.changes;
this.loadKycData();
} else {
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan = 'PAN verification failed.';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.pan = false;
this.verificationStatus.pan = 'FAILED';
this.verificationError.pan =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'PAN verification failed. Please try again.';
}
});
});
}, 'pan');
}
/** Show Aadhaar input in summary view for re-verification */
@@ -305,31 +368,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.aadhaarOtpSent = false;
this.aadhaarOtp = '';
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
this.saveKycBeforeVerify(() => {
this.clientsService
.sendAadhaarOtp(this.clientId, this.kycId, aadhaar)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.aadhaar = false;
const refNumber =
response?.changes?.aadhaarRefNumber ||
response?.aadhaarRefNumber ||
response?.changes?.referenceNumber ||
response?.resourceId;
this.aadhaarOtpSent = true;
this.aadhaarOtpReferenceId = refNumber || null;
this.verificationStatus.aadhaar = 'PENDING';
},
error: (err: any) => {
this.verifying.aadhaar = false;
this.verificationStatus.aadhaar = 'FAILED';
this.verificationError.aadhaar =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'Failed to send OTP. Please try again.';
}
});
}, 'aadhaar');
}
/** Step 2: Verify OTP entered by user */
@@ -380,31 +445,33 @@ export class KycTabComponent implements OnInit, OnDestroy {
this.verificationError.gstin = null;
this.verificationDetails.gstin = null;
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.saveKycBeforeVerify(() => {
this.clientsService
.verifyGSTIN(this.clientId, this.kycId)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (response: any) => {
this.verifying.gstin = false;
if (response?.changes?.gstinVerified) {
this.verificationStatus.gstin = 'VERIFIED';
this.verificationDetails.gstin = response.changes;
this.loadKycData();
} else {
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin = 'GSTIN verification failed.';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
},
error: (err: any) => {
this.verifying.gstin = false;
this.verificationStatus.gstin = 'FAILED';
this.verificationError.gstin =
err?.error?.developerMessage ||
err?.error?.defaultUserMessage ||
err?.message ||
'GSTIN verification failed. Please try again.';
}
});
});
}, 'gstin');
}
formatDate(date: any): string {
+22
View File
@@ -958,4 +958,26 @@ export class ClientsService {
dispatchWelcomeKit(clientId: string | number, data: any): Observable<any> {
return this.http.post(`/clients/${clientId}/welcomekit`, data);
}
getKycExceptions(resolved = false): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions?resolved=${resolved}`);
}
/**
* Records a KYC-exception outreach attempt against a customer, and — for the SMS, EMAIL and
* WHATSAPP channels — actually transmits the notice.
*
* The destination is resolved server-side from the client record; it is deliberately not part of
* this payload, so a tampered request cannot redirect a KYC notice elsewhere.
*
* Resolves with HTTP 200 even when delivery fails — inspect `deliveryStatus` / `delivered` on the
* response rather than assuming success, since the log row is written either way.
*/
saveKycContactLog(clientId: string | number, data: { channel: string; notes: string; officerName?: string }): Observable<any> {
return this.http.post(`/kyc/exceptions/${clientId}/contact-log`, data);
}
getKycContactLog(clientId: string | number): Observable<any[]> {
return this.http.get<any[]>(`/kyc/exceptions/${clientId}/contact-log`);
}
}
@@ -430,8 +430,20 @@ export class CreateClientComponent {
}
this.validationError = '';
// Save KYC
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({ error: () => {} });
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: (res: any) => {
if (this.clientKycStep) {
this.clientKycStep.kycId = res?.resourceId?.toString() || res?.entityId?.toString() || '';
}
},
error: () => {}
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({ error: () => {} });
}
}
// Upload photo & signature
if (this.clientKycStep?.photographFile) {
@@ -696,12 +708,20 @@ export class CreateClientComponent {
// Persist the KYC document picker selection + uploaded files as a client identifier
this.saveKycDocProof(clientId);
// Save KYC only if it wasn't already created during the KYC step
if (this.clientKycStep?.hasKycData && !this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
// Save/update KYC to ensure latest exception fields are persisted
if (this.clientKycStep?.hasKycData) {
if (!this.clientKycStep.kycId) {
this.clientsService.saveClientKyc(clientId, this.clientKycStep.kycData).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
} else {
const { kycType, ...updatePayload } = this.clientKycStep.kycData;
this.clientsService.updateClientKyc(clientId, this.clientKycStep.kycId, updatePayload).subscribe({
next: () => navigateToClient(),
error: () => navigateToClient()
});
}
} else {
navigateToClient();
}
+175
View File
@@ -0,0 +1,175 @@
/**
* 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 {
deriveKycExceptionFields,
KycExceptionInput,
KYC_EXCEPTION_AADHAAR,
KYC_EXCEPTION_PAN
} from './kyc-exception.util';
const TODAY = '05 August 2026';
/** Individual client, nothing previously recorded — the client-creation case. */
const newIndividual = (panVerified: boolean, aadhaarVerified: boolean): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: false,
previousReason: null,
todayFormatted: TODAY
});
/** Individual client that already carries an open exception. */
const existingException = (
panVerified: boolean,
aadhaarVerified: boolean,
previousReason: string
): KycExceptionInput => ({
panVerified,
aadhaarVerified,
aadhaarApplicable: true,
hadException: true,
previousReason,
todayFormatted: TODAY
});
describe('deriveKycExceptionFields', () => {
describe('at client creation', () => {
it('marks both PAN and Aadhaar when neither is entered or verified', () => {
expect(deriveKycExceptionFields(newIndividual(false, false))).toEqual({
isException: true,
exceptionReason: `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only PAN when Aadhaar verified but PAN did not', () => {
expect(deriveKycExceptionFields(newIndividual(false, true))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_PAN,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('marks only Aadhaar when PAN verified but Aadhaar did not', () => {
expect(deriveKycExceptionFields(newIndividual(true, false))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('raises nothing when both verify, and does not mark the client resolved', () => {
// A clean client never had an exception, so it must not appear in the Resolved tab either.
expect(deriveKycExceptionFields(newIndividual(true, true))).toEqual({
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('does not raise an Aadhaar exception for a non-individual client', () => {
// Companies submit a dummy masked Aadhaar; checking it would mean a permanent exception.
expect(
deriveKycExceptionFields({
panVerified: true,
aadhaarVerified: false,
aadhaarApplicable: false,
hadException: false,
previousReason: null,
todayFormatted: TODAY
}).isException
).toBe(false);
});
});
describe('resolving', () => {
it('closes the exception once both checks pass, keeping the reason for the audit trail', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(true, true, both))).toEqual({
isException: false,
exceptionReason: both,
exceptionResolved: true,
exceptionResolvedOn: TODAY
});
});
it('narrows to the outstanding item when only one of two verifies', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
// PAN verified, Aadhaar still outstanding — stays open, listing Aadhaar alone.
expect(deriveKycExceptionFields(existingException(true, false, both))).toEqual({
isException: true,
exceptionReason: KYC_EXCEPTION_AADHAAR,
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('narrows the other way round too', () => {
const both = `${KYC_EXCEPTION_PAN}, ${KYC_EXCEPTION_AADHAAR}`;
expect(deriveKycExceptionFields(existingException(false, true, both)).exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
describe('re-opening', () => {
it('clears a prior resolution so a new exception returns to Pending', () => {
// Regression: without exceptionResolved=false the record still matched the Resolved query
// and a brand-new exception appeared as already closed.
const result = deriveKycExceptionFields({
panVerified: false,
aadhaarVerified: true,
aadhaarApplicable: true,
hadException: false,
previousReason: 'PAN Card Verification Failed',
todayFormatted: TODAY
});
expect(result.exceptionResolved).toBe(false);
expect(result.exceptionResolvedOn).toBeNull();
expect(result.isException).toBe(true);
});
});
describe('officer-entered reasons', () => {
it('keeps a manual reason alongside the automatic ones', () => {
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: ['Address proof pending']
});
expect(result).toEqual({
isException: true,
exceptionReason: 'Address proof pending',
exceptionResolved: false,
exceptionResolvedOn: null
});
});
it('drops a stale automatic reason left in the form once the check passes', () => {
// The form still holds 'PAN Card Verification Failed' from a previous load; PAN has since
// verified, so it must not survive into the payload.
const result = deriveKycExceptionFields({
...newIndividual(true, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.isException).toBe(false);
expect(result.exceptionReason).toBeNull();
});
it('does not duplicate a manual reason that matches an outstanding automatic one', () => {
const result = deriveKycExceptionFields({
...newIndividual(false, true),
manualReasons: [KYC_EXCEPTION_PAN]
});
expect(result.exceptionReason).toBe(KYC_EXCEPTION_PAN);
});
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* 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/.
*/
/**
* Derives a client's KYC exception state from their verification status.
*
* A KYC exception is not something an officer should have to remember to tick — it is simply the
* fact that a mandatory identity check has not passed. Deriving it means the exception list can
* never drift out of step with reality: raising, narrowing and closing all fall out of whether PAN
* and Aadhaar are verified at the moment of the save.
*
* Shared by the client-creation stepper and the client-view KYC tab so both write identical state.
*/
export const KYC_EXCEPTION_PAN = 'PAN Card Verification Failed';
export const KYC_EXCEPTION_AADHAAR = 'Aadhaar Verification Failed';
/** The two reasons this module owns. Anything else on a record was added by an officer by hand. */
export const AUTO_MANAGED_KYC_EXCEPTION_REASONS: string[] = [
KYC_EXCEPTION_PAN,
KYC_EXCEPTION_AADHAAR
];
export interface KycExceptionInput {
/** PAN verified against the Income Tax database. Unverified OR simply not entered → exception. */
panVerified: boolean;
/** Aadhaar verified against UIDAI. Unverified OR simply not entered → exception. */
aadhaarVerified: boolean;
/**
* False for non-individual clients, where no Aadhaar is collected (the payload sends a dummy
* masked value). Without this guard every company would carry a permanent Aadhaar exception.
*/
aadhaarApplicable: boolean;
/** Whether an exception is already recorded — decides raise vs. resolve on a clean save. */
hadException: boolean;
/** Reason currently persisted, kept verbatim when closing so the audit trail survives. */
previousReason: string | null;
/** Officer-entered reasons beyond the two automatic ones; carried through untouched. */
manualReasons?: string[];
/** Today in the tenant's date format — stamped as exceptionResolvedOn when closing. */
todayFormatted: string;
}
export interface KycExceptionFields {
isException: boolean;
exceptionReason: string | null;
exceptionResolved: boolean;
exceptionResolvedOn: string | null;
}
/**
* Maps verification status to the four exception columns the API expects.
*
* Three outcomes:
* - **Something outstanding** → raise/keep the exception, listing only what is still failing. A
* record that had both PAN and Aadhaar outstanding and has since verified PAN comes back with
* Aadhaar alone, which is what narrows the entry rather than closing it.
* - **Nothing outstanding, exception existed** → close it. `exceptionReason` is retained on
* purpose: the Resolved tab shows what was resolved, and clearing it would erase the record of
* why the exception was ever raised.
* - **Nothing outstanding, no exception** → leave every flag off, so a clean client never appears
* in either tab.
*/
export function deriveKycExceptionFields(input: KycExceptionInput): KycExceptionFields {
const outstanding: string[] = [];
if (!input.panVerified) {
outstanding.push(KYC_EXCEPTION_PAN);
}
if (input.aadhaarApplicable && !input.aadhaarVerified) {
outstanding.push(KYC_EXCEPTION_AADHAAR);
}
// Officer-added reasons ride along, but the two automatic ones are filtered out first — a stale
// 'PAN Card Verification Failed' left in the form must not survive PAN actually being verified.
for (const reason of input.manualReasons || []) {
const trimmed = (reason || '').trim();
if (trimmed && !AUTO_MANAGED_KYC_EXCEPTION_REASONS.includes(trimmed) && !outstanding.includes(trimmed)) {
outstanding.push(trimmed);
}
}
if (outstanding.length > 0) {
return {
isException: true,
exceptionReason: outstanding.join(', '),
exceptionResolved: false,
exceptionResolvedOn: null
};
}
if (input.hadException) {
return {
isException: false,
exceptionReason: input.previousReason || null,
exceptionResolved: true,
exceptionResolvedOn: input.todayFormatted
};
}
return {
isException: false,
exceptionReason: null,
exceptionResolved: false,
exceptionResolvedOn: null
};
}
+21
View File
@@ -579,6 +579,19 @@
<button mat-menu-item [routerLink]="['/reports', 'Accounting']" tabindex="0">
<i class="fa fa-calculator menu-item-icon"></i> Accounting
</button>
<mat-divider></mat-divider>
<button mat-menu-item routerLink="/reports/form-97-98" tabindex="0">
<i class="fa fa-file-invoice menu-item-icon"></i> Form 97/98 Transaction Report
</button>
<button mat-menu-item routerLink="/reports/risk-review" tabindex="0">
<i class="fa fa-user-shield menu-item-icon"></i> Risk Review Report
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<i class="fa fa-percent menu-item-icon"></i> Deposit Interest &amp; TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<i class="fa fa-landmark menu-item-icon"></i> TDS Remitted to Government
</button>
</mat-menu>
<!-- Admin Mega Menu — 3-column rectangular panel grouping Setup,
@@ -1485,6 +1498,14 @@
<button mat-menu-item routerLink="/letter-mgmt/form-15gh" tabindex="0">
<fa-icon icon="file-signature" class="menu-item-icon" style="color: #15803d"></fa-icon> Form 121
</button>
<button mat-menu-item routerLink="/reports/tds-interest" tabindex="0">
<fa-icon icon="percentage" class="menu-item-icon" style="color: #15803d"></fa-icon> Deposit Interest &amp;
TDS
</button>
<button mat-menu-item routerLink="/reports/tds-remittance" tabindex="0">
<fa-icon icon="building-columns" class="menu-item-icon" style="color: #15803d"></fa-icon> TDS Remitted to
Govt
</button>
<button mat-menu-item routerLink="/letter-mgmt/hypothecation-certificate" tabindex="0">
<fa-icon icon="file-alt" class="menu-item-icon" style="color: #15803d"></fa-icon>
Hypothecation Certificate
@@ -29,6 +29,11 @@
Account Status
<span class="km-cnt km-cnt--red">34</span>
</button>
<button class="km-itab" [class.active]="activeSection === 'exceptions'" (click)="showSection('exceptions')">
<fa-icon icon="exclamation-triangle"></fa-icon>
KYC Exceptions
<span class="km-cnt km-cnt--amb">{{ exceptionRows.length }}</span>
</button>
</div>
<div class="km-main">
@@ -722,6 +727,228 @@
</div>
</div>
}
<!-- KYC EXCEPTIONS -->
@if (activeSection === 'exceptions') {
<div class="km-page">
<div class="km-page-head">
<span class="km-page-title">KYC Exceptions Dashboard</span>
<div class="km-actions">
<button class="km-btn km-btn-g" (click)="toast('Exporting exception report...')">
<fa-icon icon="download"></fa-icon> Export Exceptions
</button>
</div>
</div>
<div class="km-stat-row">
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-amber)">{{ exceptionRows.length }}</div>
<div class="km-stat-l">Pending Exceptions</div>
<div class="km-stat-t" style="color: var(--km-amber)">Immediate attention</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-red)">
{{ notYetContactedCount }}
</div>
<div class="km-stat-l">Not Yet Contacted</div>
<div class="km-stat-t" style="color: var(--km-red)">Action required</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-orange)">
{{ panMismatchesCount }}
</div>
<div class="km-stat-l">PAN Mismatches</div>
<div class="km-stat-t" style="color: var(--km-orange)">Requires physical review</div>
</div>
<div class="km-stat">
<div class="km-stat-n" style="color: var(--km-blue)">
{{ aadhaarFailuresCount }}
</div>
<div class="km-stat-l">Aadhaar Failures</div>
<div class="km-stat-t" style="color: var(--km-blue)">Re-KYC OTP needed</div>
</div>
</div>
<div class="km-card km-mt">
<!-- Tab Toggle Header -->
<div class="km-ch" style="border-bottom: 1px solid var(--km-g200); padding-bottom: 0; display: flex; flex-direction: column; align-items: flex-start; gap: 12px;">
<div style="display: flex; gap: 8px; align-items: center;">
<div class="km-ci" style="background: #fef3c7; color: #d97706;"><fa-icon icon="exclamation-triangle"></fa-icon></div>
<div class="km-ct">Exceptions Register</div>
</div>
<div class="km-inner-nav" style="width: 100%; border: none; margin: 10px 0 0 0; padding: 0; gap: 8px;">
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'pending'"
(click)="activeExceptionSubTab = 'pending'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px;"
>
<fa-icon icon="clock"></fa-icon>
Pending Exceptions ({{ pendingExceptions.length }})
</button>
<button
class="km-itab"
[class.active]="activeExceptionSubTab === 'resolved'"
(click)="activeExceptionSubTab = 'resolved'"
style="padding: 8px 16px; border-radius: 4px; font-weight: 600; font-size: 13px;"
>
<fa-icon icon="check-circle"></fa-icon>
Resolved Exceptions ({{ resolvedExceptions.length }})
</button>
</div>
</div>
<!-- PENDING EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'pending') {
@if (pendingExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center;">
<div class="km-empty-i">🎉</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px;">All exceptions resolved!</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px;">There are no active client KYC exceptions on record.</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Date</th>
<th>Risk</th>
<th>Contact Status</th>
<th>Contact Logs</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (c of pendingExceptions; track c.id) {
<tr>
<td>
<div class="km-an" [routerLink]="['/clients', c.id, 'kyc']" style="cursor: pointer; color: var(--km-blue); text-decoration: underline;">{{ c.name }}</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: #b45309;">{{ c.exceptionReason }}</div>
<div class="km-small">Opened: {{ c.exceptionDate }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<span class="km-del d-ok">Contacted</span>
<div class="km-small" style="margin-top: 2px;">Last: {{ c.contactHistory[c.contactHistory.length - 1].date }}</div>
} @else {
<span class="km-del d-fa">Not Contacted</span>
}
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px;">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0;">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }} <em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage">Sent {{ log.destinationMasked }}</span>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px;">No logs recorded</span>
}
</td>
<td>
<div style="display: flex; gap: 8px;">
<button class="km-btn km-btn-ot" style="border-color: var(--km-blue); color: var(--km-blue);" [routerLink]="['/clients', c.id, 'kyc']">
<fa-icon icon="user-check"></fa-icon> Do KYC
</button>
<button class="km-btn km-btn-ot" (click)="openContactDialog(c)">
<fa-icon icon="phone"></fa-icon> Log Call
</button>
</div>
</td>
</tr>
}
</tbody>
</table>
}
}
<!-- RESOLVED EXCEPTIONS TAB -->
@if (activeExceptionSubTab === 'resolved') {
@if (resolvedExceptions.length === 0) {
<div class="km-empty" style="padding: 40px; text-align: center;">
<div class="km-empty-i" style="font-size: 32px; filter: grayscale(1);">📋</div>
<div style="font-weight: 500; font-size: 16px; margin-top: 12px; color: var(--km-g500);">No resolved exceptions</div>
<div class="km-empty-s" style="color: var(--km-g500); font-size: 13px; margin-top: 4px;">Resolved KYC exceptions will appear here once verification is completed.</div>
</div>
} @else {
<table class="km-tbl">
<thead>
<tr>
<th>Client / Account</th>
<th>Branch</th>
<th>Exception Reason &amp; Resolution</th>
<th>Risk</th>
<th>Contact Logs</th>
<th>Status</th>
</tr>
</thead>
<tbody>
@for (c of resolvedExceptions; track c.id) {
<tr>
<td>
<div class="km-an" [routerLink]="['/clients', c.id, 'kyc']" style="cursor: pointer; color: var(--km-blue); text-decoration: underline;">{{ c.name }}</div>
<div class="km-ai">{{ c.account }}</div>
</td>
<td>{{ c.contact.branch }}</td>
<td>
<div style="font-weight: 500; color: var(--km-g500); text-decoration: line-through;">{{ c.exceptionReason }}</div>
<div class="km-small">Resolved On: {{ c.exceptionResolvedOn }}</div>
</td>
<td>
<span class="km-b" [ngClass]="riskClass(c.risk)">{{ c.riskLabel }}</span>
</td>
<td>
@if (c.contactHistory && c.contactHistory.length > 0) {
<div class="contact-logs-list" style="max-height: 80px; overflow-y: auto; font-size: 11px;">
@for (log of c.contactHistory; track $index) {
<div style="border-bottom: 1px dashed var(--km-g300); padding: 2px 0;">
<strong>{{ log.date }} ({{ log.action }}):</strong> {{ log.notes }} <em>— {{ log.officer }}</em>
@if (log.platformSent) {
@if (log.delivered) {
<span class="km-sent-tag km-sent-ok" [title]="log.deliveryMessage">Sent {{ log.destinationMasked }}</span>
} @else {
<span class="km-sent-tag km-sent-fail" [title]="log.deliveryMessage">Not sent</span>
}
}
</div>
}
</div>
} @else {
<span style="color: var(--km-g500); font-size: 11px;">No logs recorded</span>
}
</td>
<td>
<span style="display: inline-flex; align-items: center; gap: 4px; background: #e6f4ea; color: #137333; padding: 4px 8px; border-radius: 4px; font-weight: 500; font-size: 12px;">
<fa-icon icon="check-circle"></fa-icon> Exception Resolved
</span>
</td>
</tr>
}
</tbody>
</table>
}
}
</div>
</div>
}
</div>
<!-- CLIENT DETAIL MODAL -->
@@ -1165,6 +1392,202 @@
</div>
}
<!-- LOG CONTACT DIALOG -->
@if (contactDialogOpen && current) {
<div class="km-overlay" (click)="closeContactDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh">
<div class="km-mh-av">{{ current.av }}</div>
<div>
<div class="km-mh-name">Log Customer Contact</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeContactDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div class="hv-addr">
<div class="hv-addr-label">Customer Contact Info</div>
<div class="hv-addr-name">{{ current.name }}</div>
<div class="hv-addr-text">
<fa-icon icon="mobile-alt" class="vkyc-ic"></fa-icon> {{ current.contact.mobile }}
</div>
<div class="hv-addr-text">
<fa-icon icon="envelope" class="vkyc-ic"></fa-icon> {{ current.contact.email }}
</div>
</div>
<div class="hv-row">
<label class="hv-label">Contact Channel<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactChannel">
@for (ch of contactChannels; track ch.value) {
<option [value]="ch.value">{{ ch.label }}{{ ch.sends ? ' — sends a notice now' : ' — record only' }}</option>
}
</select>
@if (selectedChannelSends) {
<div class="hv-hint hv-hint-send">
<fa-icon icon="paper-plane" class="vkyc-ic"></fa-icon>
A KYC notice will be sent to the customer when you save.
</div>
} @else {
<div class="hv-hint">
Records an action you already took. Nothing is sent to the customer.
</div>
}
@if (selectedChannelUnavailable) {
<div class="hv-hint hv-hint-error">
This customer has no {{ selectedChannel?.requires === 'email' ? 'email address' : 'mobile number' }} on file —
choose a different channel.
</div>
}
</div>
<div class="hv-row">
<label class="hv-label">KYC Officer Logging<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="contactOfficer">
@for (officer of bankOfficers; track officer) {
<option [value]="officer">{{ officer }}</option>
}
</select>
</div>
<div class="hv-row">
<label class="hv-label">Summary / Notes<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Enter details of conversation e.g. Customer promised to visit branch next Tuesday..."
[(ngModel)]="contactNotes"
></textarea>
</div>
@if (contactError) {
<div class="hv-hint hv-hint-error" role="alert">
<fa-icon icon="exclamation-circle" class="vkyc-ic"></fa-icon> {{ contactError }}
</div>
}
</div>
<div class="hv-footer">
<button class="km-btn" [disabled]="contactSaving" (click)="closeContactDialog()">Cancel</button>
<button
class="km-btn km-btn-t"
[disabled]="contactSaveDisabled"
(click)="saveContactLog()"
>
{{ contactSaveLabel }}
</button>
</div>
</div>
</div>
}
<!-- RESOLVE KYC DIALOG -->
@if (resolveDialogOpen && current) {
<div class="km-overlay" (click)="closeResolveDialog()">
<div class="km-modal hv-modal" (click)="$event.stopPropagation()">
<div class="km-mh" style="border-bottom: 2px solid var(--km-green);">
<div class="km-mh-av" style="background: var(--km-green);">{{ current.av }}</div>
<div>
<div class="km-mh-name">Resolve KYC Exception</div>
<div class="km-mh-meta">{{ current.name }} · {{ current.account }}</div>
</div>
<div class="km-mclose" (click)="closeResolveDialog()">✕</div>
</div>
<div class="km-mbody hv-body">
<div style="background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 6px; padding: 12px; margin-bottom: 16px; font-size: 13px; color: #166534;">
<strong>Pending Reason:</strong> {{ current.exceptionReason }}
@if (current.exceptionRemarks) {
<div style="margin-top: 4px; font-style: italic;">"{{ current.exceptionRemarks }}"</div>
}
</div>
@if (showPanResolution) {
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<input type="checkbox" id="resolvePanChk" [(ngModel)]="resolvePanActive" />
<label for="resolvePanChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve PAN Card Exception</label>
</div>
@if (resolvePanActive) {
<div class="hv-row" style="margin-left: 20px;">
<label class="hv-label">Verified PAN Number<span class="hv-req">*</span></label>
<input type="text" class="hv-input" [(ngModel)]="resolvePanNumber" placeholder="e.g. ABCDE1234F" maxlength="10" style="text-transform: uppercase;" />
@if (resolvePanNumber && resolvePanNumber.length !== 10) {
<div style="color: red; font-size: 11px; margin-top: 4px;">PAN must be exactly 10 characters.</div>
}
</div>
}
</div>
}
@if (showAadhaarResolution) {
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<input type="checkbox" id="resolveAadhChk" [(ngModel)]="resolveAadhaarActive" />
<label for="resolveAadhChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve Aadhaar Card Exception</label>
</div>
@if (resolveAadhaarActive) {
<div class="hv-row" style="margin-left: 20px;">
<label class="hv-label">Verified Aadhaar Number<span class="hv-req">*</span></label>
<input type="text" class="hv-input" [(ngModel)]="resolveAadhaarNumber" placeholder="e.g. 597855025299" maxlength="12" />
@if (resolveAadhaarNumber && resolveAadhaarNumber.length !== 12) {
<div style="color: red; font-size: 11px; margin-top: 4px;">Aadhaar must be exactly 12 digits.</div>
}
</div>
}
</div>
}
@if (showGeneralResolution) {
<div style="border: 1px solid var(--km-g300); border-radius: 6px; padding: 12px; margin-bottom: 16px; background: #fafafa;">
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 8px;">
<input type="checkbox" id="resolveGenChk" [(ngModel)]="resolveGeneralActive" />
<label for="resolveGenChk" style="font-weight: 600; cursor: pointer; color: var(--km-g800);">Resolve General Exception</label>
</div>
@if (resolveGeneralActive) {
<div style="margin-left: 20px;">
<div class="hv-row">
<label class="hv-label">Verification Document Type<span class="hv-req">*</span></label>
<select class="hv-input" [(ngModel)]="resolveGeneralDocumentType">
<option value="OVD">Officially Valid Document (Passport/Voter ID)</option>
</select>
</div>
<div class="hv-row">
<label class="hv-label">Document Identification Number<span class="hv-req">*</span></label>
<input type="text" class="hv-input" [(ngModel)]="resolveGeneralDocumentNumber" placeholder="Enter verified document number" />
</div>
</div>
}
</div>
}
<div class="hv-row">
<label class="hv-label">Resolution Comments<span class="hv-req">*</span></label>
<textarea
class="hv-input hv-textarea"
rows="3"
placeholder="Describe how verification was resolved..."
[(ngModel)]="resolveRemarks"
></textarea>
</div>
</div>
<div class="hv-footer">
<button class="km-btn" (click)="closeResolveDialog()">Cancel</button>
<button
class="km-btn km-btn-g"
[disabled]="!isResolveValid"
(click)="saveResolveKyc()"
>
Complete KYC &amp; Resolve Exception
</button>
</div>
</div>
</div>
}
<!-- TOAST -->
<div class="km-toast" [class.show]="toastVisible">{{ toastMsg }}</div>
</div>
@@ -1588,6 +1588,58 @@
min-height: 60px;
}
/* Inline note under a field. The default (neutral) form states that nothing is transmitted;
the -send and -error variants call out the two cases the officer must not misread. */
.hv-hint {
font-size: 11px;
line-height: 1.45;
color: var(--km-g500);
display: flex;
align-items: flex-start;
gap: 6px;
}
.hv-hint-send {
color: #0f766e;
background: #f0fdfa;
border: 1px solid #99f6e4;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
.hv-hint-error {
color: #b91c1c;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 4px;
padding: 6px 8px;
font-weight: 500;
}
/* Delivery outcome chip on a contact-log row. A logged attempt and a delivered notice are
different facts, so they never share a colour. */
.km-sent-tag {
display: inline-block;
margin-left: 6px;
padding: 0 5px;
border-radius: 3px;
font-size: 10px;
font-weight: 600;
white-space: nowrap;
vertical-align: baseline;
}
.km-sent-ok {
background: #e6f4ea;
color: #137333;
}
.km-sent-fail {
background: #fef2f2;
color: #b91c1c;
}
.hv-footer {
padding: 14px 22px;
border-top: 1px solid var(--km-g200);
@@ -1648,3 +1700,28 @@
color: #0f172a;
word-break: break-all;
}
/* KYC Exceptions Styles */
.km-ci-e {
background: var(--km-amber-l) !important;
color: var(--km-amber) !important;
}
.contact-logs-list {
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: 6px 10px;
font-family: inherit;
&::-webkit-scrollbar {
width: 4px;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 2px;
}
}
@@ -6,10 +6,11 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit } from '@angular/core';
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { ClientsService } from '../clients/clients.service';
interface KmNotice {
no: string;
@@ -31,6 +32,24 @@ interface KmEvidence {
docket?: string;
}
/** One outreach attempt, as returned by GET /kyc/exceptions/{clientId}/contact-log. */
interface KmContactLog {
date: string;
/** Display label for the channel used, e.g. 'SMS' / 'In-Branch Visit'. */
action: string;
notes: string;
officer: string;
/** True when this channel transmits (SMS/Email/WhatsApp) rather than being recorded offline. */
platformSent?: boolean;
/** True only when the notice actually reached the gateway. */
delivered?: boolean;
/** SENT | FAILED | SKIPPED_NO_DESTINATION | PROVIDER_NOT_CONFIGURED | NOT_APPLICABLE */
deliveryStatus?: string;
/** Officer-facing outcome sentence — shown verbatim so a failure is never mistaken for a send. */
deliveryMessage?: string;
destinationMasked?: string;
}
interface KmClient {
id: string;
av: string;
@@ -66,6 +85,17 @@ interface KmClient {
pendingActions: string[];
actionTaken: string[];
evidence: KmEvidence | null;
isException?: boolean;
exceptionReason?: string;
exceptionRemarks?: string;
exceptionDate?: string;
exceptionResolved?: boolean;
exceptionResolvedOn?: string;
contactHistory?: KmContactLog[];
kycId?: string;
/** Whether the customer has a mobile / email on file — drives channel availability in the dialog. */
hasMobile?: boolean;
hasEmail?: boolean;
}
@Component({
@@ -79,7 +109,8 @@ interface KmClient {
]
})
export class KycManagementComponent implements OnInit {
activeSection: 'alerts' | 'notif' | 'audit' | 'status' = 'alerts';
private clientsService = inject(ClientsService);
activeSection: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions' = 'alerts';
modalOpen = false;
modalTab: 'overview' | 'timeline' | 'notices' | 'evidence' | 'action' = 'overview';
@@ -529,15 +560,399 @@ export class KycManagementComponent implements OnInit {
};
alertRows: KmClient[] = [];
exceptionRows: KmClient[] = [];
pendingExceptions: KmClient[] = [];
resolvedExceptions: KmClient[] = [];
activeExceptionSubTab: 'pending' | 'resolved' = 'pending';
selectedExceptionReason = '';
selectedExceptionContactStatus = '';
// Dialog properties for exceptions
contactDialogOpen = false;
contactChannel = 'PHONE_CALL';
contactNotes = '';
contactOfficer = 'Neha Verma — KYC Officer (Bhopal Main)';
/** In-flight guard — a double-click must not send the customer two notices. */
contactSaving = false;
/** Populated when the save itself failed (not when the save worked but delivery didn't). */
contactError = '';
/**
* Channels offered in the Log Customer Contact dialog. `sends` marks the ones where saving
* transmits a real notice to the customer, as opposed to recording an action the officer already
* took offline — the dialog labels and the confirm button both key off it.
*/
readonly contactChannels: { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' }[] = [
{ value: 'PHONE_CALL', label: 'Phone Call', sends: false },
{ value: 'SMS', label: 'SMS', sends: true, requires: 'mobile' },
{ value: 'EMAIL', label: 'Email', sends: true, requires: 'email' },
{ value: 'WHATSAPP', label: 'WhatsApp Message', sends: true, requires: 'mobile' },
{ value: 'BRANCH_VISIT', label: 'In-Branch Visit', sends: false },
{ value: 'FIELD_VISIT', label: 'Agent Field Visit', sends: false }
];
resolveDialogOpen = false;
resolveDocumentType = 'PAN';
resolveDocumentNumber = '';
resolveRemarks = '';
resolvePanActive = false;
resolvePanNumber = '';
resolveAadhaarActive = false;
resolveAadhaarNumber = '';
resolveGeneralActive = false;
resolveGeneralDocumentType = 'OVD';
resolveGeneralDocumentNumber = '';
showPanResolution = false;
showAadhaarResolution = false;
showGeneralResolution = false;
ngOnInit(): void {
this.alertRows = Object.values(this.clients);
this.loadExceptions();
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status'): void {
loadExceptions(): void {
this.clientsService.getKycExceptions(false).subscribe({
next: (data) => {
this.pendingExceptions = data.map((item: any) => this.mapException(item, false));
this.exceptionRows = this.pendingExceptions;
this.loadContactHistories(this.pendingExceptions);
},
error: (err) => {
console.error('Failed to load active exceptions', err);
this.pendingExceptions = [];
this.exceptionRows = [];
}
});
this.clientsService.getKycExceptions(true).subscribe({
next: (data) => {
this.resolvedExceptions = data.map((item: any) => this.mapException(item, true));
this.loadContactHistories(this.resolvedExceptions);
},
error: (err) => {
console.error('Failed to load resolved exceptions', err);
this.resolvedExceptions = [];
}
});
}
private mapException(item: any, resolved: boolean): KmClient {
return {
id: item.clientId?.toString(),
av: (item.clientDisplayName || 'Client').split(' ').map((n: string) => n[0]).join('').substring(0, 2).toUpperCase(),
name: item.clientDisplayName,
meta: `${item.clientAccountNo || `CLI-${item.clientId}`} · Savings A/c · ${item.clientOfficeName || 'Bhopal Main'}`,
account: item.clientAccountNo || `CLI-${item.clientId}`,
state: 'alert',
risk: item.riskCategoryCode?.toLowerCase() || 'low',
riskLabel: item.riskCategory || 'Low Risk',
expiry: 'Pending',
daysLeft: 30,
kyc: {
type: item.kycType || 'Individual',
initiated: item.initiatedOn || '—',
lastVerified: item.verifiedOn || '—',
validity: '—',
pan: item.panNumber || '—',
aadhaar: item.aadhaarNumberMasked || '—',
panStatus: item.panVerified ? 'Verified' : 'Verification Failed',
aadhaarStatus: item.aadhaarVerified ? 'Verified' : 'Verification Failed',
panLink: 'Not Linked'
},
contact: {
// Masked server-side; the full values never reach the browser. 'Not on file' is meaningful
// here — it's why an SMS or Email channel will be unavailable for this customer.
mobile: item.clientMobileMasked || 'Not on file',
email: item.clientEmailMasked || 'Not on file',
address: 'Client Address, Bhopal MP 462002',
branch: item.clientOfficeName || 'Bhopal Main'
},
notices: [],
noticesSent: 0,
noticePending: resolved ? 'Resolved Exception' : 'Pending verification',
nextStage: resolved ? 'Exception Resolved' : 'KYC Exceptions review',
pendingActions: resolved ? [] : ['Resolve Exception reasons'],
actionTaken: resolved ? ['KYC Exception resolved'] : [],
evidence: null,
isException: true,
// No invented default — a record whose reason was never stored (or was cleared by the old
// resolve path) must not be relabelled as a PAN failure it may never have had.
exceptionReason: item.exceptionReason || 'Reason not recorded',
exceptionRemarks: item.remarks || '',
exceptionDate: item.initiatedOn || '—',
exceptionResolved: resolved,
exceptionResolvedOn: item.exceptionResolvedOn || '—',
contactHistory: [],
kycId: item.id?.toString(),
hasMobile: item.hasMobile === true,
hasEmail: item.hasEmail === true
};
}
/**
* Pull each row's persisted outreach history. Fired after the exception list loads; a failure on
* one row leaves that row's history empty rather than blanking the whole grid.
*/
private loadContactHistories(rows: KmClient[]): void {
for (const row of rows) {
if (!row.id) continue;
this.clientsService.getKycContactLog(row.id).subscribe({
next: (logs) => {
row.contactHistory = (logs || []).map((log: any) => this.mapContactLog(log));
},
error: (err) => {
console.error(`Failed to load contact log for client ${row.id}`, err);
}
});
}
}
get notYetContactedCount(): number {
return this.exceptionRows.filter(c => !c.contactHistory || c.contactHistory.length === 0).length;
}
get panMismatchesCount(): number {
return this.exceptionRows.filter(c => c.exceptionReason?.includes('PAN')).length;
}
get aadhaarFailuresCount(): number {
return this.exceptionRows.filter(c => c.exceptionReason?.includes('Aadhaar')).length;
}
showSection(section: 'alerts' | 'notif' | 'audit' | 'status' | 'exceptions'): void {
this.activeSection = section;
}
openContactDialog(client: KmClient): void {
this.current = client;
this.contactChannel = 'PHONE_CALL';
this.contactNotes = '';
this.contactError = '';
this.contactSaving = false;
this.contactOfficer = this.bankOfficers[1]; // Neha Verma
this.contactDialogOpen = true;
}
closeContactDialog(): void {
this.contactDialogOpen = false;
}
/** The channel currently picked in the dialog. */
get selectedChannel(): { value: string; label: string; sends: boolean; requires?: 'mobile' | 'email' } | undefined {
return this.contactChannels.find(c => c.value === this.contactChannel);
}
/** True when saving will transmit a notice to the customer, not merely record one. */
get selectedChannelSends(): boolean {
return this.selectedChannel?.sends === true;
}
/**
* True when the picked channel needs a destination the customer doesn't have. Blocks the save so
* the officer isn't left reading a failure they could have seen up front.
*/
get selectedChannelUnavailable(): boolean {
const requires = this.selectedChannel?.requires;
if (!requires || !this.current) return false;
return requires === 'mobile' ? this.current.hasMobile === false : this.current.hasEmail === false;
}
get contactSaveDisabled(): boolean {
return !this.contactNotes?.trim() || !this.contactChannel || this.contactSaving || this.selectedChannelUnavailable;
}
/** 'Send & Save' when a notice goes out, plain 'Save Log' when it's an offline record. */
get contactSaveLabel(): string {
if (this.contactSaving) return this.selectedChannelSends ? 'Sending…' : 'Saving…';
return this.selectedChannelSends ? 'Send & Save' : 'Save Log';
}
saveContactLog(): void {
if (!this.current || this.contactSaveDisabled) return;
const client = this.current;
this.contactSaving = true;
this.contactError = '';
this.clientsService
.saveKycContactLog(client.id, {
channel: this.contactChannel,
notes: this.contactNotes.trim(),
officerName: this.contactOfficer
})
.subscribe({
next: (log: any) => {
this.contactSaving = false;
client.contactHistory = [...(client.contactHistory || []), this.mapContactLog(log)];
// The request succeeding does not mean the customer was reached — a gateway failure comes
// back as HTTP 200 with delivered=false, so report what actually happened.
if (log?.platformSent && !log?.delivered) {
this.toast(log?.deliveryMessage || `Logged, but the ${log?.channelLabel || 'message'} could not be sent.`);
} else {
this.toast(log?.deliveryMessage || `Contact attempt logged successfully for ${client.name}`);
}
this.contactDialogOpen = false;
},
error: (err) => {
this.contactSaving = false;
// Keep the dialog open — the officer's notes are still in the textarea and would
// otherwise be lost.
this.contactError =
err?.error?.errors?.[0]?.defaultUserMessage ||
err?.error?.defaultUserMessage ||
'Could not save the contact log. Please try again.';
}
});
}
private mapContactLog(log: any): KmContactLog {
return {
date: log?.contactedAt ? this.formatLogDate(log.contactedAt) : '—',
action: log?.channelLabel || log?.channel || 'Contact',
notes: log?.notes || '',
officer: (log?.officerName || '').split(' — ')[0],
platformSent: log?.platformSent,
delivered: log?.delivered,
deliveryStatus: log?.deliveryStatus,
deliveryMessage: log?.deliveryMessage,
destinationMasked: log?.destinationMasked
};
}
/**
* `contactedAt` arrives as a LocalDateTime — Jackson serialises it as an ISO string or as a
* [y, m, d, h, min, s] array depending on the tenant's date-format setting, so handle both.
*/
private formatLogDate(value: any): string {
const date = Array.isArray(value)
? new Date(value[0], (value[1] || 1) - 1, value[2] || 1, value[3] || 0, value[4] || 0)
: new Date(value);
if (isNaN(date.getTime())) return '—';
return date.toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' });
}
get isResolveValid(): boolean {
if (!this.resolveRemarks?.trim()) return false;
let anyActive = false;
if (this.showPanResolution && this.resolvePanActive) {
anyActive = true;
if (!this.resolvePanNumber || this.resolvePanNumber.trim().length !== 10) return false;
}
if (this.showAadhaarResolution && this.resolveAadhaarActive) {
anyActive = true;
if (!this.resolveAadhaarNumber || this.resolveAadhaarNumber.trim().length !== 12) return false;
}
if (this.showGeneralResolution && this.resolveGeneralActive) {
anyActive = true;
if (!this.resolveGeneralDocumentNumber?.trim()) return false;
}
return anyActive;
}
openResolveDialog(client: KmClient): void {
this.current = client;
const reason = client.exceptionReason || '';
this.showPanResolution = reason.includes('PAN');
this.showAadhaarResolution = reason.includes('Aadhaar');
this.showGeneralResolution = !this.showPanResolution && !this.showAadhaarResolution;
this.resolvePanActive = this.showPanResolution;
this.resolveAadhaarActive = this.showAadhaarResolution;
this.resolveGeneralActive = this.showGeneralResolution;
this.resolvePanNumber = '';
this.resolveAadhaarNumber = '';
this.resolveGeneralDocumentType = 'OVD';
this.resolveGeneralDocumentNumber = '';
this.resolveRemarks = '';
this.resolveDialogOpen = true;
}
closeResolveDialog(): void {
this.resolveDialogOpen = false;
}
saveResolveKyc(): void {
if (!this.current || !this.current.kycId) return;
const clientId = this.current.id;
const kycId = this.current.kycId;
const today = new Date();
const day = today.getDate().toString().padStart(2, '0');
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const monthStr = months[today.getMonth()];
const year = today.getFullYear();
const dateStr = `${day} ${monthStr} ${year}`;
const resolvingPan = this.showPanResolution && this.resolvePanActive;
const resolvingAadhaar = this.showAadhaarResolution && this.resolveAadhaarActive;
const resolvingGeneral = this.showGeneralResolution && this.resolveGeneralActive;
const remainsPan = this.showPanResolution && !this.resolvePanActive;
const remainsAadhaar = this.showAadhaarResolution && !this.resolveAadhaarActive;
const isFullyResolved = !remainsPan && !remainsAadhaar;
const resolvedKycData: any = {
locale: 'en',
dateFormat: 'dd MMMM yyyy',
remarks: this.resolveRemarks || 'Resolved via exceptions dashboard'
};
if (isFullyResolved) {
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// Keep exceptionReason — the Resolved tab shows what was resolved, and clearing it destroys
// the audit record of why the exception was ever raised.
} else {
resolvedKycData.isException = true;
resolvedKycData.exceptionResolved = false;
const remainingReasons: string[] = [];
if (remainsPan) remainingReasons.push('PAN Card Verification Failed');
if (remainsAadhaar) remainingReasons.push('Aadhaar Verification Failed');
resolvedKycData.exceptionReason = remainingReasons.join(', ');
}
if (resolvingPan) {
resolvedKycData.panNumber = this.resolvePanNumber.toUpperCase().trim();
resolvedKycData.panVerified = true;
}
if (resolvingAadhaar) {
const aadhaarRaw = this.resolveAadhaarNumber.trim();
resolvedKycData.aadhaarNumberMasked = 'XXXXXXXX' + aadhaarRaw.slice(-4);
resolvedKycData.aadhaarVerified = true;
}
if (resolvingGeneral) {
resolvedKycData.kycDocumentName = this.resolveGeneralDocumentType;
resolvedKycData.kycDocumentNumber = this.resolveGeneralDocumentNumber;
resolvedKycData.isException = false;
resolvedKycData.exceptionResolved = true;
resolvedKycData.exceptionResolvedOn = dateStr;
// exceptionReason preserved — see the fully-resolved branch above.
}
this.clientsService.updateClientKyc(clientId, kycId, resolvedKycData).subscribe({
next: () => {
const msg = isFullyResolved
? `KYC Exception fully resolved and completed for ${this.current?.name}`
: `KYC Exception partially resolved for ${this.current?.name}`;
this.toast(msg);
this.loadExceptions();
this.resolveDialogOpen = false;
},
error: (err) => {
console.error('Failed to resolve exception', err);
this.toast('Failed to resolve exception. Please try again.');
}
});
}
openModal(id: string): void {
const c = this.clients[id];
if (!c) return;
@@ -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'
}.`
);
@@ -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'
}
];
}
@@ -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>
}
@@ -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
+12
View File
@@ -43,6 +43,18 @@ const routes: Routes = [
data: { title: 'Sanctions Logs', breadcrumb: 'Sanctions Logs' },
component: SanctionsLogsComponent
},
{
path: 'tds-interest',
data: { title: 'Deposit Interest & TDS', breadcrumb: 'Deposit Interest & TDS' },
loadComponent: () =>
import('../tds-reports/tds-interest-report.component').then((m) => m.TdsInterestReportComponent)
},
{
path: 'tds-remittance',
data: { title: 'TDS Remitted to Government', breadcrumb: 'TDS Remitted to Government' },
loadComponent: () =>
import('../tds-reports/tds-remittance-report.component').then((m) => m.TdsRemittanceReportComponent)
},
{
path: ':filter',
data: { routeParamBreadcrumb: 'filter' },
@@ -0,0 +1,325 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="tdsr">
<div class="tdsr__head">
<div class="tdsr__head-l">
<fa-icon icon="percentage" class="tdsr__head-ic"></fa-icon>
<div>
<h2 class="tdsr__title">Deposit Interest &amp; TDS</h2>
<div class="tdsr__sub">
Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
{{ scopeLabel }}
</div>
</div>
</div>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
<!-- Summary -->
<div class="tdsr__kpis">
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.applicableInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on FD / RD</div>
</div>
<div class="tdsr__kpi tdsr__kpi--muted">
<div class="tdsr__kpi-n">₹{{ summary.exemptInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Savings interest — outside the charge</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalTds | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">TDS payable</div>
</div>
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.crossed | number }}</div>
<div class="tdsr__kpi-l">Crossed the threshold</div>
</div>
<button type="button" class="tdsr__kpi tdsr__kpi--action" (click)="showNearThreshold()">
<div class="tdsr__kpi-n">{{ summary.nearThreshold | number }}</div>
<div class="tdsr__kpi-l">Within ₹{{ nearBand | number: '1.0-0' }} of it — chase a declaration</div>
</button>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.tdsSaved | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Saved by {{ summary.declarations | number }} declarations</div>
</div>
@if (summary.noPan > 0) {
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">{{ summary.noPan | number }}</div>
<div class="tdsr__kpi-l">No PAN — deducted at 20%</div>
</div>
}
</div>
<!-- Quarter strip -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="calendar"></fa-icon> Quarter by quarter</div>
<div class="tdsr__qstrip">
@for (q of summary.byQuarter; track q.quarter) {
<div class="tdsr__qcell">
<div class="tdsr__qcell-l">{{ quarterLabel[q.quarter] }}</div>
<div class="tdsr__qcell-n">₹{{ q.interest | number: '1.0-0' }}</div>
<div class="tdsr__qcell-s">TDS ₹{{ q.tds | number: '1.0-0' }}</div>
</div>
}
</div>
</div>
<!-- Filters -->
<div class="tdsr__filters">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="filter.fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
@if (canSelectBranch) {
<label class="tdsr__filter">
<span>Branch</span>
<select [(ngModel)]="filter.branchCode">
<option value="">All branches</option>
@for (b of branches; track b.code) {
<option [value]="b.code">{{ b.name }}</option>
}
</select>
</label>
}
<label class="tdsr__filter">
<span>Quarter</span>
<select [(ngModel)]="filter.quarter">
<option [ngValue]="0">Whole year</option>
@for (q of quarters; track q) {
<option [ngValue]="q">{{ quarterLabel[q] }}</option>
}
</select>
</label>
<label class="tdsr__filter">
<span>Threshold band</span>
<select [(ngModel)]="filter.band">
<option value="">Both bands</option>
<option value="GENERAL">General (below 60)</option>
<option value="SENIOR">Senior citizen (60+)</option>
</select>
</label>
<label class="tdsr__filter tdsr__filter--wide">
<span>Customer / CIF / PAN</span>
<input type="text" [(ngModel)]="filter.search" placeholder="name, CIF or PAN" />
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.crossedOnly" />
<span>Threshold crossed only</span>
</label>
<label class="tdsr__check">
<input type="checkbox" [(ngModel)]="filter.nearThresholdOnly" />
<span>Near threshold only</span>
</label>
@if (hasFilters) {
<button type="button" class="tdsr__clear" (click)="clearFilters()"><fa-icon icon="times"></fa-icon> Clear</button>
}
</div>
<!-- Table -->
<div class="tdsr__card">
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Customer · PAN</th>
<th>Branch</th>
<th>Form · Band</th>
<th class="num">Q1</th>
<th class="num">Q2</th>
<th class="num">Q3</th>
<th class="num">Q4</th>
<th class="num">Year total</th>
<th class="num">Threshold</th>
<th>Crossed</th>
<th class="num">TDS</th>
<th class="num">Saved</th>
<th class="col-actions"></th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="!row.panOnRecord" [class.tdsr__row--near]="row.nearThreshold">
<td>
<div class="tdsr__name">{{ row.deductee.name }}</div>
<div class="mono small muted">
{{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
{{ row.deductee.pan }}
} @else {
<span class="tdsr__pill tdsr__pill--warn">no PAN</span>
}
</div>
</td>
<td class="small">{{ branchName(row.branchCode) }}</td>
<td>
<span class="tdsr__pill" [class.tdsr__pill--ok]="row.hasDeclaration">
{{ row.form === '121' ? '121' : row.form }}
</span>
<div class="small muted">{{ row.band === 'SENIOR' ? 'Senior' : 'General' }}</div>
@if (row.hasDeclaration) {
<div class="mono small muted">{{ row.declarationUin }}</div>
}
</td>
@for (q of quarters; track q) {
<td class="num small">
@if (row.quarters[q]) {
₹{{ row.quarters[q] | number: '1.0-0' }}
} @else {
<span class="muted">—</span>
}
</td>
}
<td class="num">
<strong>₹{{ row.tdsApplicableInterest | number: '1.0-0' }}</strong>
@if (row.exemptInterest) {
<div class="small muted">+₹{{ row.exemptInterest | number: '1.0-0' }} SB (exempt)</div>
}
</td>
<td class="num small muted">₹{{ row.threshold | number: '1.0-0' }}</td>
<td class="small">
@if (row.crossedInQuarter) {
<span class="tdsr__pill tdsr__pill--warn">Q{{ row.crossedInQuarter }}</span>
} @else if (row.nearThreshold) {
<span class="tdsr__pill tdsr__pill--near"> ₹{{ row.headroom | number: '1.0-0' }} left </span>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.schedule.totalTds) {
<strong>₹{{ row.schedule.totalTds | number: '1.0-0' }}</strong>
<div class="small muted">@ {{ row.rateApplied * 100 | number: '1.0-0' }}%</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="num">
@if (row.tdsSaved) {
<strong class="tdsr__ok">₹{{ row.tdsSaved | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="col-actions">
<button type="button" class="tdsr__icon-btn" (click)="toggleExpanded(row)">
<fa-icon [icon]="isExpanded(row) ? 'chevron-up' : 'chevron-down'"></fa-icon>
</button>
</td>
</tr>
@if (isExpanded(row)) {
<tr class="tdsr__row--detail">
<td colspan="13">
@for (note of row.schedule.notes; track note) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>{{ note }}</div>
</div>
}
@if (row.jointHeldInterest) {
<div class="tdsr__note">
<fa-icon icon="info-circle"></fa-icon>
<div>
₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest sits on deposits where this customer
is a joint holder but not the first-named one. Tax there is deducted against the first-named
holder, so it is excluded from the figures above.
</div>
</div>
}
<table class="tdsr__table tdsr__table--inner">
<thead>
<tr>
<th>Quarter</th>
<th class="num">Interest</th>
<th class="num">Cumulative</th>
<th class="num">Liability to date</th>
<th class="num">TDS this quarter</th>
<th>Return due</th>
</tr>
</thead>
<tbody>
@for (line of row.schedule.lines; track line.quarter) {
<tr [class.tdsr__row--warn]="line.thresholdCrossedHere">
<td class="small">
{{ line.label }}
@if (line.thresholdCrossedHere) {
<span class="tdsr__pill tdsr__pill--warn">crossed here</span>
}
@if (line.declarationActive) {
<span class="tdsr__pill tdsr__pill--ok">Form 121 active</span>
}
</td>
<td class="num">₹{{ line.interest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumInterest | number: '1.0-0' }}</td>
<td class="num">₹{{ line.cumLiability | number: '1.0-0' }}</td>
<td class="num">
@if (line.tds !== 0) {
<strong>₹{{ line.tds | number: '1.0-0' }}</strong>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ line.dueDate }}</td>
</tr>
}
</tbody>
</table>
<div class="tdsr__meta">
<span
>Settlement: <strong>{{ row.mode === 'QUARTERLY' ? 'Quarterly' : 'Year end' }}</strong> (set by
the deposit product)</span
>
@if (row.declarationAcceptedOn) {
<span
>Declaration accepted: <strong>{{ row.declarationAcceptedOn | date: 'd MMM y' }}</strong></span
>
}
@if (row.paidWithoutDeduction) {
<span>
Paid without deduction:
<strong>₹{{ row.paidWithoutDeduction | number: '1.0-0' }}</strong> — reportable in
{{ statute.statement }}
</span>
}
</div>
</td>
</tr>
}
}
@if (rows.length === 0) {
<tr>
<td colspan="13" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No deductees match the current filters.
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
@@ -0,0 +1,250 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, OnInit, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { GL_BRANCHES, glBranchName } from '../gl-reports/gl-reports-data';
import { FY_TAX_CONFIG, Quarter, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_FYS } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsInterestRow,
TdsInterestSummary,
TdsReportFilter,
tdsInterestRows,
tdsInterestSummary
} from './tds-reports.compute';
@Component({
selector: 'mifosx-tds-interest-report',
templateUrl: './tds-interest-report.component.html',
styleUrls: ['./tds-reports.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class TdsInterestReportComponent implements OnInit {
private scope = inject(BranchScopeService);
readonly branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
readonly financialYears = TDS_FYS;
readonly quarterLabel = QUARTER_LABEL;
readonly nearBand = NEAR_THRESHOLD_BAND;
readonly quarters: Quarter[] = [
1,
2,
3,
4
];
filter: TdsReportFilter = {
fy: TDS_FYS[0],
branchCode: '',
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
/** Which customer's quarter-wise detail is open. */
expandedId: string | null = null;
exporting = false;
ngOnInit(): void {
// A branch login sees only its own book and cannot widen the scope.
const own = this.scope.ownBranchCode;
if (own) {
this.filter.branchCode = own;
}
}
/**
* Provision and return references for the selected year. The charge moved from
* section 194A to section 393(1) on 01 April 2026 and the quarterly return
* became Form 140, so the labels are read from the config rather than written
* into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.filter.fy];
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
get canSelectBranch(): boolean {
return this.scope.canSelectBranch;
}
get scopeLabel(): string {
return this.filter.branchCode ? glBranchName(this.filter.branchCode) : 'All branches';
}
get rows(): TdsInterestRow[] {
return tdsInterestRows(this.filter);
}
get summary(): TdsInterestSummary {
return tdsInterestSummary(this.filter);
}
get hasFilters(): boolean {
return !!(
this.filter.search ||
this.filter.quarter ||
this.filter.band ||
this.filter.crossedOnly ||
this.filter.nearThresholdOnly
);
}
clearFilters(): void {
this.filter = {
...this.filter,
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false
};
}
/** Jump straight to the customers worth chasing a declaration from. */
showNearThreshold(): void {
this.filter = { ...this.filter, crossedOnly: false, nearThresholdOnly: true };
}
toggleExpanded(row: TdsInterestRow): void {
this.expandedId = this.expandedId === row.deductee.customerId ? null : row.deductee.customerId;
}
isExpanded(row: TdsInterestRow): boolean {
return this.expandedId === row.deductee.customerId;
}
branchName(code: string): string {
return glBranchName(code);
}
trackRow(_: number, row: TdsInterestRow): string {
return row.deductee.customerId;
}
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(`TDS Interest FY ${this.filter.fy}`);
ws.addRow([`Deposit Interest & TDS — FY ${this.filter.fy}`]);
ws.addRow([`Scope: ${this.scopeLabel}`]);
ws.addRow([]);
ws.addRow([
'Customer',
'CIF',
'PAN',
'Branch',
'Form',
'Band',
'Age',
'Q1',
'Q2',
'Q3',
'Q4',
'Interest (FD/RD)',
'Savings interest (exempt)',
'Threshold',
'Crossed in',
'Rate %',
'TDS payable',
'Declaration',
'UIN',
'TDS saved',
'Paid without deduction'
]);
for (const r of this.rows) {
ws.addRow([
r.deductee.name,
r.deductee.customerId,
r.deductee.pan || 'NOT ON RECORD',
this.branchName(r.branchCode),
r.form === '121' ? 'Form 121' : `Form ${r.form}`,
r.band === 'SENIOR' ? 'Senior citizen' : 'General',
r.age,
r.quarters[1],
r.quarters[2],
r.quarters[3],
r.quarters[4],
r.tdsApplicableInterest,
r.exemptInterest,
r.threshold,
r.crossedInQuarter ? `Q${r.crossedInQuarter}` : '—',
r.rateApplied * 100,
r.schedule.totalTds,
r.hasDeclaration ? 'Yes' : 'No',
r.declarationUin ?? '',
r.tdsSaved,
r.paidWithoutDeduction
]);
}
const s = this.summary;
ws.addRow([]);
ws.addRow([
'TOTAL',
`${s.deductees} deductees`,
'',
'',
'',
'',
'',
s.byQuarter[0].interest,
s.byQuarter[1].interest,
s.byQuarter[2].interest,
s.byQuarter[3].interest,
s.applicableInterest,
s.exemptInterest,
'',
'',
'',
s.totalTds,
`${s.declarations} filed`,
'',
s.tdsSaved,
s.paidWithoutDeduction
]);
ws.getRow(1).font = { bold: true, size: 13 };
ws.getRow(4).font = { bold: true };
ws.columns.forEach((c) => (c.width = 18));
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Interest-FY${this.filter.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
this.exporting = false;
}
}
}
@@ -0,0 +1,237 @@
<!--
Copyright since 2025 Mifos Initiative
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
-->
<div class="tdsr">
<div class="tdsr__head">
<div class="tdsr__head-l">
<fa-icon icon="building-columns" class="tdsr__head-ic"></fa-icon>
<div>
<h2 class="tdsr__title">TDS Remitted to Government</h2>
<div class="tdsr__sub">
Challan-wise deposits and the {{ statute.statement }} year-end position · {{ scopeLabel }} · as at
{{ asAt | date: 'd MMM y' }}
</div>
</div>
</div>
<div class="tdsr__head-r">
<label class="tdsr__filter">
<span>Financial year</span>
<select [(ngModel)]="fy">
@for (year of financialYears; track year) {
<option [value]="year">FY {{ year }}</option>
}
</select>
</label>
<button type="button" class="tdsr__btn" (click)="exportWorkbook()" [disabled]="exporting">
<fa-icon icon="download"></fa-icon>
{{ exporting ? 'Preparing…' : 'Export' }}
</button>
</div>
</div>
<!-- The control figure, first thing on the page -->
@if (reconciled) {
<div class="tdsr__banner tdsr__banner--ok">
<fa-icon icon="check-circle"></fa-icon>
<div>
<strong>Reconciled.</strong>
₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
summary.totalDeposited | number: '1.0-0'
}}
deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
</div>
</div>
} @else {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.</strong>
Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
return.
</div>
</div>
}
@if (summary.overdueChallans > 0) {
<div class="tdsr__banner tdsr__banner--warn">
<fa-icon icon="exclamation-triangle"></fa-icon>
<div>
<strong>{{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.</strong>
Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
</div>
</div>
}
<!-- KPIs -->
<div class="tdsr__kpis">
<div class="tdsr__kpi tdsr__kpi--warn">
<div class="tdsr__kpi-n">₹{{ summary.totalDeducted | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Total TDS deducted</div>
</div>
<div class="tdsr__kpi tdsr__kpi--ok">
<div class="tdsr__kpi-n">₹{{ summary.totalDeposited | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Deposited with Government</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">₹{{ summary.pendingDeposit | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Within due date</div>
</div>
<div class="tdsr__kpi" [class.tdsr__kpi--warn]="totalDefaultInterest > 0">
<div class="tdsr__kpi-n">₹{{ totalDefaultInterest | number: '1.0-0' }}</div>
<div class="tdsr__kpi-l">Interest on late deposit</div>
</div>
<div class="tdsr__kpi">
<div class="tdsr__kpi-n">{{ summary.deductees | number }}</div>
<div class="tdsr__kpi-l">Deductees in the return</div>
</div>
</div>
<!-- Part A: challans -->
<div class="tdsr__card">
<div class="tdsr__card-head"><fa-icon icon="receipt"></fa-icon> Part A — challan-wise remittance</div>
<div class="tdsr__table-wrap">
<table class="tdsr__table">
<thead>
<tr>
<th>Quarter</th>
<th>Deduction month</th>
<th class="num">Amount</th>
<th>Due date</th>
<th>Deposited on</th>
<th>Challan · BSR</th>
<th>Status</th>
<th class="num">Late-deposit interest</th>
<th>{{ statute.statement }} due</th>
</tr>
</thead>
<tbody>
@for (row of rows; track trackRow($index, row)) {
<tr [class.tdsr__row--warn]="row.late || row.overdue">
<td>
<strong>{{ quarterLabel[row.quarter] }}</strong>
</td>
<td class="small">{{ row.month }}</td>
<td class="num">
<strong>₹{{ row.amount | number: '1.0-0' }}</strong>
</td>
<td class="small">{{ row.dueDate | date: 'd MMM y' }}</td>
<td class="small">
@if (row.depositedOn) {
{{ row.depositedOn | date: 'd MMM y' }}
} @else {
<span class="muted">—</span>
}
</td>
<td class="mono small">
@if (row.challanNo) {
{{ row.challanNo }}
<div class="muted">BSR {{ row.bsrCode }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td>
@if (row.overdue) {
<span class="tdsr__pill tdsr__pill--warn">Overdue</span>
} @else if (row.late) {
<span class="tdsr__pill tdsr__pill--warn">Late by {{ row.delayMonths }}m</span>
} @else if (row.depositedOn) {
<span class="tdsr__pill tdsr__pill--ok">On time</span>
} @else {
<span class="tdsr__pill">Within due date</span>
}
</td>
<td class="num">
@if (row.defaultInterest) {
<strong class="tdsr__warn-text">₹{{ row.defaultInterest | number: '1.0-0' }}</strong>
<div class="small muted">1.5% × {{ row.delayMonths }}</div>
} @else {
<span class="muted">—</span>
}
</td>
<td class="small muted">{{ row.returnDueDate }}</td>
</tr>
}
@if (rows.length === 0) {
<tr>
<td colspan="9" class="tdsr__empty">
<fa-icon icon="info-circle"></fa-icon> No tax was deducted in this financial year.
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="tdsr__foot">
Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
</div>
</div>
<!-- Part B: return summary -->
<div class="tdsr__card">
<div class="tdsr__card-head">
<fa-icon icon="file-alt"></fa-icon> Part B — {{ statute.statement }} year-end position
</div>
<div class="tdsr__ledger">
<div class="tdsr__ledger-row">
<span>Total interest paid on time deposits</span>
<span class="num">₹{{ summary.totalInterestPaid | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>with tax deducted</span>
<span class="num">₹{{ summary.interestWithDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--indent">
<span>
without deduction — declaration on record
<em>mandatory disclosure in {{ statute.statement }}</em>
</span>
<span class="num">₹{{ summary.interestWithoutDeduction | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>TDS at standard rate — PAN on record</span>
<span class="num">₹{{ summary.tdsAtStandardRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>TDS at higher rate — no PAN on record</span>
<span class="num">₹{{ summary.tdsAtHigherRate | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--total">
<span>Total TDS deducted</span>
<span class="num">₹{{ summary.totalDeducted | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Deposited with Government</span>
<span class="num">₹{{ summary.totalDeposited | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row">
<span>Still within due date</span>
<span class="num">₹{{ summary.pendingDeposit | number: '1.0-0' }}</span>
</div>
<div class="tdsr__ledger-row tdsr__ledger-row--control" [class.tdsr__ledger-row--bad]="!reconciled">
<span>Difference — must be zero</span>
<span class="num">₹{{ summary.difference | number: '1.0-0' }}</span>
</div>
@if (summary.defaultInterest > 0) {
<div class="tdsr__ledger-row tdsr__ledger-row--sep">
<span>Interest payable by the bank for late deposit</span>
<span class="num">₹{{ summary.defaultInterest | number: '1.0-0' }}</span>
</div>
}
</div>
<div class="tdsr__foot">
The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the
quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of
declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current
specification before generating the file.
</div>
</div>
</div>
@@ -0,0 +1,203 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { Component, inject } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import * as ExcelJS from 'exceljs';
import { BranchScopeService } from '../core/branch-scope.service';
import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS } from './tds-reports-data';
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
@Component({
selector: 'mifosx-tds-remittance-report',
templateUrl: './tds-remittance-report.component.html',
styleUrls: ['./tds-reports.shared.scss'],
imports: [
...STANDALONE_SHARED_IMPORTS,
FaIconComponent,
FormsModule
]
})
export class TdsRemittanceReportComponent {
private scope = inject(BranchScopeService);
readonly financialYears = TDS_FYS;
readonly quarterLabel = QUARTER_LABEL;
readonly asAt = TDS_AS_AT;
fy = TDS_FYS[0];
exporting = false;
/**
* Provision and return references for the selected year. The quarterly return
* became Form 140 under the 2025 Act, so the labels are read from the config
* rather than written into the template.
*/
get statute(): { section: string; statement: string; frequency: string } {
const cfg = FY_TAX_CONFIG[this.fy];
return {
section: cfg?.interestTdsSection ?? '—',
statement: cfg?.tdsStatementForm ?? '—',
frequency: cfg?.declarationStatementFrequency === 'MONTHLY' ? 'monthly' : 'quarterly'
};
}
get scopeLabel(): string {
return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName;
}
get rows(): TdsRemittanceRow[] {
return tdsRemittanceRows(this.fy, this.asAt);
}
get summary(): Tds26qSummary {
return tds26qSummary(this.fy, this.asAt);
}
/**
* The control that matters. Tax deducted must equal tax deposited plus tax
* still within its due date. Anything else means something has gone missing
* and must be found before the return is filed.
*/
get reconciled(): boolean {
return this.summary.difference === 0;
}
get totalDefaultInterest(): number {
return this.summary.defaultInterest;
}
trackRow(_: number, row: TdsRemittanceRow): string {
return `${row.fy}-${row.quarter}`;
}
async exportWorkbook(): Promise<void> {
this.exporting = true;
try {
const s = this.summary;
const wb = new ExcelJS.Workbook();
const challan = wb.addWorksheet('Challans');
challan.addRow([`TDS remitted to Government — FY ${this.fy}`]);
challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]);
challan.addRow([]);
challan.addRow([
'Quarter',
'Deduction month',
'Amount deducted',
'Due date',
'Deposited on',
'Challan no.',
'BSR code',
'Status',
'Delay (months)',
'Late-deposit interest',
`${this.statute.statement} due`
]);
for (const r of this.rows) {
challan.addRow([
`Q${r.quarter}`,
r.month,
r.amount,
r.dueDate,
r.depositedOn ?? 'PENDING',
r.challanNo ?? '',
r.bsrCode ?? '',
r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
r.delayMonths || '',
r.defaultInterest || '',
r.returnDueDate
]);
}
challan.getRow(1).font = { bold: true, size: 13 };
challan.getRow(4).font = { bold: true };
challan.columns.forEach((c) => (c.width = 18));
const recon = wb.addWorksheet('Return Summary');
recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]);
recon.addRow([]);
const lines: [string, number | string][] = [
[
'Deductees',
s.deductees
],
[
'Total interest paid on time deposits',
s.totalInterestPaid
],
[
' — with tax deducted',
s.interestWithDeduction
],
[
' — without deduction (declaration on record)',
s.interestWithoutDeduction
],
[
'TDS at standard rate (PAN on record)',
s.tdsAtStandardRate
],
[
'TDS at higher rate (no PAN)',
s.tdsAtHigherRate
],
[
'Total TDS deducted',
s.totalDeducted
],
[
'Total deposited with Government',
s.totalDeposited
],
[
'Still within due date',
s.pendingDeposit
],
[
'DIFFERENCE (must be zero)',
s.difference
],
[
'Interest payable for late deposit',
s.defaultInterest
],
[
'Challans deposited late',
s.lateChallans
],
[
'Challans overdue',
s.overdueChallans
]
];
for (const line of lines) {
recon.addRow(line);
}
recon.getRow(1).font = { bold: true, size: 13 };
recon.columns.forEach((c) => (c.width = 42));
recon.getColumn(2).width = 20;
const buffer = await wb.xlsx.writeBuffer();
const blob = new Blob([buffer], {
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `TDS-Remittance-FY${this.fy}.xlsx`;
a.click();
URL.revokeObjectURL(url);
} finally {
this.exporting = false;
}
}
}
+396
View File
@@ -0,0 +1,396 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Deductees and their deposit interest, for the two TDS reports.
*
* The bank's deduction obligation runs on time deposits, so the seed
* gives each customer a mix of fixed and recurring deposits — and some savings
* accounts, which are outside the section and must stay out of the aggregate.
*
* Everything is generated from a fixed seed so the figures do not move between
* loads, and the challan schedule is deliberately imperfect: one quarter is
* deposited late so the default-interest column has something to show.
*/
import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
import {
Declaration,
Form15Kind,
InterestCredit,
TdsAccountInput
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
/** The years the seed covers. 2025-26 is complete; 2026-27 is in progress. */
export const TDS_FYS = [
'2026-27',
'2025-26'
];
/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
export const TDS_AS_AT = '2026-08-01';
export interface TdsDeductee {
clientId: number;
customerId: string;
name: string;
pan: string;
dob: string;
branchCode: string;
/** Declarations on record, keyed by financial year. */
declarations: Record<string, Declaration>;
accounts: TdsAccountInput[];
}
/** One month's remittance of deducted tax to the Government. */
export interface TdsChallan {
fy: string;
/** Quarter the deduction belongs to. */
quarter: 1 | 2 | 3 | 4;
/** Month the tax was deducted in, e.g. '2026-06'. */
month: string;
amount: number;
/** Statutory date by which it had to be deposited. */
dueDate: string;
/** When it actually reached the Government — null while still pending. */
depositedOn: string | null;
challanNo: string | null;
bsrCode: string | null;
}
/** Deterministic linear congruential generator — same figures on every load. */
function lcg(seed: number): () => number {
let s = seed >>> 0;
return () => {
s = (Math.imul(s, 1664525) + 1013904223) >>> 0;
return s / 4294967296;
};
}
function pick<T>(rand: () => number, list: T[]): T {
return list[Math.floor(rand() * list.length)];
}
const FIRST_NAMES = [
'Ramesh',
'Sunita',
'Vitthal',
'Kamala',
'Ganpat',
'Shobha',
'Dnyaneshwar',
'Mangala',
'Bhaskar',
'Anita',
'Tukaram',
'Sarika',
'Nivrutti',
'Rekha',
'Popat',
'Chhaya',
'Ashok',
'Vaishali',
'Sitaram',
'Jayshree',
'Madhukar',
'Pushpa',
'Eknath',
'Sulochana',
'Hanumant',
'Kalpana'
];
const SURNAMES = [
'Jadhav',
'Pawar',
'Shinde',
'Gaikwad',
'More',
'Kadam',
'Bhosale',
'Patil',
'Sawant',
'Chavan',
'Salunkhe',
'Mane',
'Deshmukh',
'Thorat',
'Nikam',
'Wagh'
];
/**
* Quarter-end dates for a financial year. Interest is credited on these, which
* is what makes the deduction fall due.
*/
function quarterEnds(fy: string): string[] {
const y = Number(fy.slice(0, 4));
return [
`${y}-06-30`,
`${y}-09-30`,
`${y}-12-31`,
`${y + 1}-03-31`
];
}
/** A well-formed individual PAN — fourth character 'P'. */
function makePan(rand: () => number, surname: string): string {
const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26));
const digits = `${1000 + Math.floor(rand() * 9000)}`;
return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`;
}
let deducteeCache: TdsDeductee[] | null = null;
/**
* The bank's deductees.
*
* The spread is deliberate so both reports have every case in them: customers
* comfortably under the trigger, customers sitting just below it, customers well
* over, senior citizens on the higher threshold, a few with no PAN on record
* (deducted at the 206AA rate), some joint deposits held as second holder, and
* some declarations that only came in mid-year.
*/
export function tdsDeductees(): TdsDeductee[] {
if (deducteeCache) {
return deducteeCache;
}
const rand = lcg(20260801);
const branches = GL_BRANCHES.filter((b) => b.code !== 'HO');
const out: TdsDeductee[] = [];
for (let i = 0; i < 64; i++) {
const branch = branches[i % branches.length];
const first = pick(rand, FIRST_NAMES);
const last = pick(rand, SURNAMES);
const clientId = 5000 + i;
// A third of the book is senior citizens, which is about right for a
// co-operative bank's deposit base.
const senior = rand() < 0.34;
const birthYear = senior ? 1948 + Math.floor(rand() * 15) : 1972 + Math.floor(rand() * 22);
const dob = `${birthYear}-${`${1 + Math.floor(rand() * 12)}`.padStart(2, '0')}-${`${1 + Math.floor(rand() * 28)}`.padStart(2, '0')}`;
// Every seventeenth customer has no PAN on record, so tax comes off at the
// higher section 206AA rate. Fixed by index rather than drawn at random, so
// the case is always present.
const hasPan = i % 17 !== 3;
// Aim each customer's annual time-deposit interest at a band, so the report
// has a proper spread either side of the trigger.
const draw = rand();
let annualInterest: number;
const limit = senior ? 100_000 : 50_000;
if (draw < 0.3) {
annualInterest = 4_000 + Math.floor(rand() * (limit - 10_000)); // comfortably under
} else if (draw < 0.45) {
annualInterest = limit - 6_000 + Math.floor(rand() * 6_000); // just below the threshold
} else if (draw < 0.8) {
annualInterest = limit + 2_000 + Math.floor(rand() * 40_000); // over
} else {
annualInterest = 110_000 + Math.floor(rand() * 320_000); // well over
}
// Push two of the no-PAN customers over the trigger. Left to chance they can
// all land under it, and then the 20% line of the return summary comes out
// empty — a report line that is never exercised is one nobody has checked.
if (!hasPan && (i === 3 || i === 37)) {
annualInterest = 70_000 + i * 400;
}
const accounts: TdsAccountInput[] = [];
const acctBase = 41_00_000 + i * 137;
// One or two time deposits, split across the year's quarter ends.
const depositCount = rand() < 0.4 ? 2 : 1;
for (let d = 0; d < depositCount; d++) {
const isRd = rand() < 0.3;
const share = depositCount === 1 ? 1 : d === 0 ? 0.6 : 0.4;
const credits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
// The current year is only part-run, so later quarters have no postings.
const ends = quarterEnds(fy).filter((date) => date <= TDS_AS_AT);
if (!ends.length) {
continue;
}
// The prior year ran in full; scale the current year to what has posted.
const perQuarter = Math.round((annualInterest * share) / 4);
for (const date of ends) {
credits.push({ date, amount: perQuarter });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + d}`.padStart(8, '0')}`,
depositTypeCode: isRd ? 'depositAccountType.recurringDeposit' : 'depositAccountType.fixedDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: {
// A fifth of the book is on annual posting, so tax on those settles
// once at year end rather than quarterly.
code: rand() < 0.2 ? 'savingsPostingInterestPeriodType.annual' : 'savingsPostingInterestPeriodType.quarterly'
},
credits
});
}
// Most customers also hold a savings account. Its interest must never reach
// the deduction aggregate — that is the point of including it here.
if (rand() < 0.75) {
const sbCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
sbCredits.push({ date, amount: 900 + Math.floor(rand() * 2_600) });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 50}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.savingsDeposit',
accountStatus: 'SINGLE',
clientId,
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: sbCredits
});
}
// One in eight is the second holder on somebody else's deposit. Tax there is
// deducted against the first-named holder, so it must not land on this
// customer's aggregate.
if (rand() < 0.12) {
const jointCredits: InterestCredit[] = [];
for (const fy of TDS_FYS) {
for (const date of quarterEnds(fy).filter((x) => x <= TDS_AS_AT)) {
jointCredits.push({ date, amount: 16_000 });
}
}
accounts.push({
accountNo: `${branch.code}${`${acctBase + 70}`.padStart(8, '0')}`,
depositTypeCode: 'depositAccountType.fixedDeposit',
accountStatus: 'JOINT',
// Somebody else is first-named.
primaryClientId: clientId + 1,
clientId: clientId + 1,
jointHolders: [{ clientId }],
interestPostingPeriodType: { code: 'savingsPostingInterestPeriodType.quarterly' },
credits: jointCredits
});
}
// Declarations. Only worth filing where tax would otherwise be deducted, so
// they cluster on the customers over the trigger.
const declarations: Record<string, Declaration> = {};
if (annualInterest > (senior ? 100_000 : 50_000) && hasPan && rand() < 0.45) {
const kind: Form15Kind = senior ? '15H' : '15G';
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
// A quarter of them only came in mid-year, which leaves tax already
// deducted that the branch cannot reverse.
const midYear = rand() < 0.25;
declarations[fy] = {
kind,
acceptedOn: midYear ? `${y}-10-01` : `${y}-04-05`,
fy,
uin: `${branch.code}${kind}${`${i + 1}`.padStart(4, '0')}`
};
}
}
out.push({
clientId,
customerId: `CIF${`${clientId}`.padStart(8, '0')}`,
name: `${first} ${last}`,
pan: hasPan ? makePan(rand, last) : '',
dob,
branchCode: branch.code,
declarations,
accounts
});
}
deducteeCache = out;
return out;
}
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
* The seventh of the following month, except for March, which runs to 30 April.
*/
export function depositDueDate(month: string): string {
const [
y,
m
] = month.split('-').map(Number);
if (m === 3) {
return `${y}-04-30`;
}
const nextY = m === 12 ? y + 1 : y;
const nextM = m === 12 ? 1 : m + 1;
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
}
let challanCache: Record<string, TdsChallan[]> | null = null;
/**
* The remittance schedule, derived from what the engine says was deducted.
*
* Deductions land at each quarter end, so there is one challan per quarter. The
* Q2 challan of the prior year is deliberately deposited a month and a bit late
* so the late-deposit interest column is exercised, and the current year's latest
* quarter is left pending, which is the ordinary state of a live book.
*/
export function tdsChallans(deductedByQuarter: Record<string, Record<number, number>>): Record<string, TdsChallan[]> {
if (challanCache) {
return challanCache;
}
const out: Record<string, TdsChallan[]> = {};
for (const fy of TDS_FYS) {
const y = Number(fy.slice(0, 4));
const months: Record<number, string> = {
1: `${y}-06`,
2: `${y}-09`,
3: `${y}-12`,
4: `${y + 1}-03`
};
const rows: TdsChallan[] = [];
for (const q of [
1,
2,
3,
4
] as (1 | 2 | 3 | 4)[]) {
const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0);
if (amount <= 0) {
continue;
}
const month = months[q];
const dueDate = depositDueDate(month);
// Anything falling after the business date has not been deposited yet.
const pending = dueDate > TDS_AS_AT;
const late = fy === '2025-26' && q === 2;
rows.push({
fy,
quarter: q,
month,
amount,
dueDate,
depositedOn: pending ? null : late ? `${y}-11-14` : dueDate,
challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`,
bsrCode: pending ? null : '0510308'
});
}
out[fy] = rows;
}
challanCache = out;
return out;
}
@@ -0,0 +1,301 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT, TDS_FYS, depositDueDate, tdsDeductees } from './tds-reports-data';
import {
NEAR_THRESHOLD_BAND,
TdsReportFilter,
deductedByQuarter,
tds26qSummary,
tdsInterestRows,
tdsInterestRowsForFy,
tdsInterestSummary,
tdsRemittanceRows
} from './tds-reports.compute';
/** The complete year in the seed — the one a return would be filed for. */
const CLOSED_FY = '2025-26';
function filter(overrides: Partial<TdsReportFilter> = {}): TdsReportFilter {
return {
fy: CLOSED_FY,
branchCode: '',
quarter: 0,
band: '',
search: '',
crossedOnly: false,
nearThresholdOnly: false,
...overrides
};
}
describe('the seed has every case the reports need to show', () => {
it('produces deductees on both sides of the threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
expect(rows.length).toBeGreaterThan(20);
expect(rows.some((r) => r.crossed)).toBe(true);
expect(rows.some((r) => !r.crossed)).toBe(true);
expect(rows.some((r) => r.nearThreshold)).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => !r.panOnRecord)).toBe(true);
expect(rows.some((r) => r.hasDeclaration)).toBe(true);
});
it('gives senior citizens the higher threshold', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
});
it('deducts at the higher rate wherever PAN is missing', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2);
}
});
});
describe('the reports follow the Act that governed each year', () => {
it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => {
const old = tdsInterestRowsForFy('2025-26');
expect(old.some((r) => r.form === '15G')).toBe(true);
expect(old.some((r) => r.form === '15H')).toBe(true);
expect(old.some((r) => r.form === '121')).toBe(false);
// The two forms merged on 01 April 2026.
const current = tdsInterestRowsForFy('2026-27');
expect(current.every((r) => r.form === '121')).toBe(true);
});
it('keeps the age-based threshold band in both years', () => {
for (const fy of TDS_FYS) {
const rows = tdsInterestRowsForFy(fy);
expect(rows.some((r) => r.band === 'SENIOR')).toBe(true);
expect(rows.some((r) => r.band === 'GENERAL')).toBe(true);
for (const row of rows) {
expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000);
}
}
});
});
describe('savings interest stays out of the 194A aggregate', () => {
it('reports it separately and never inside the applicable interest', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const withSavings = rows.filter((r) => r.exemptInterest > 0);
expect(withSavings.length).toBeGreaterThan(0);
for (const row of withSavings) {
// The applicable figure must equal only the time-deposit interest where
// this customer is the deductee.
const expected = row.deductee.accounts
.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId)
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.tdsApplicableInterest).toBe(expected);
}
});
it('leaves the savings totals out of the summary base', () => {
const s = tdsInterestSummary(filter());
expect(s.exemptInterest).toBeGreaterThan(0);
expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
});
});
describe('joint deposits are attributed to the first-named holder only', () => {
it('excludes second-holder interest from the aggregate and shows it apart', () => {
const rows = tdsInterestRowsForFy(CLOSED_FY);
const secondHolders = rows.filter((r) => r.jointHeldInterest > 0);
expect(secondHolders.length).toBeGreaterThan(0);
for (const row of secondHolders) {
const jointAccounts = row.deductee.accounts.filter(
(a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId
);
expect(jointAccounts.length).toBeGreaterThan(0);
// None of that interest may appear in the figure the threshold is tested
// against, or one deposit would be taxed against two people.
const jointTotal = jointAccounts
.flatMap((a) => a.credits)
.filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31')
.reduce((s, c) => s + c.amount, 0);
expect(row.jointHeldInterest).toBe(jointTotal);
expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal);
}
});
});
describe('quarter figures add up to the year', () => {
it('per row', () => {
for (const row of tdsInterestRowsForFy(CLOSED_FY)) {
const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4];
expect(quarterInterest).toBe(row.tdsApplicableInterest);
expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds);
}
});
it('and across the summary', () => {
const s = tdsInterestSummary(filter());
expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest);
expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds);
});
});
describe('filters', () => {
it("narrows to a branch without changing anyone's figures", () => {
const all = tdsInterestRows(filter());
const branch = all[0].branchCode;
const scoped = tdsInterestRows(filter({ branchCode: branch }));
expect(scoped.length).toBeGreaterThan(0);
expect(scoped.every((r) => r.branchCode === branch)).toBe(true);
// A branch view must not re-aggregate — the threshold is computed bank-wide.
const sample = scoped[0];
const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId);
expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest);
});
it('finds the customers worth chasing a declaration from', () => {
const near = tdsInterestRows(filter({ nearThresholdOnly: true }));
expect(near.length).toBeGreaterThan(0);
for (const row of near) {
expect(row.crossed).toBe(false);
expect(row.headroom).toBeGreaterThan(0);
expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND);
}
});
it('narrows to customers who crossed', () => {
const crossed = tdsInterestRows(filter({ crossedOnly: true }));
expect(crossed.length).toBeGreaterThan(0);
expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true);
});
it('searches on name, CIF and PAN', () => {
const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!;
expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1);
expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1);
});
});
describe('declarations', () => {
it('save tax and record the interest paid gross', () => {
const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration);
expect(declared.length).toBeGreaterThan(0);
expect(declared.some((r) => r.tdsSaved > 0)).toBe(true);
// Interest paid without deduction is a mandatory 26Q disclosure, so it must
// be carried even though no tax came off it.
expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true);
for (const row of declared) {
expect(row.declarationUin).toBeTruthy();
}
});
it('leaves tax deducted before a mid-year declaration in place', () => {
// Some declarations in the seed were accepted in October. Tax already taken
// in Q1/Q2 cannot be reversed, so those rows still carry a deduction.
const midYear = tdsInterestRowsForFy(CLOSED_FY).filter(
(r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10'
);
expect(midYear.length).toBeGreaterThan(0);
expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true);
});
});
describe('remittance schedule', () => {
it('deposits by the seventh of the following month, and 30 April for March', () => {
expect(depositDueDate('2025-06')).toBe('2025-07-07');
expect(depositDueDate('2025-12')).toBe('2026-01-07');
expect(depositDueDate('2026-03')).toBe('2026-04-30');
});
it('remits exactly what the engine says was deducted', () => {
const deducted = deductedByQuarter(CLOSED_FY);
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
for (const row of rows) {
expect(row.amount).toBe(Math.round(deducted[row.quarter]));
}
});
it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => {
const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT);
const late = rows.filter((r) => r.late);
expect(late.length).toBeGreaterThan(0);
for (const row of late) {
expect(row.delayMonths).toBeGreaterThan(0);
expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths));
}
for (const row of rows.filter((r) => !r.late && !r.overdue)) {
expect(row.defaultInterest).toBe(0);
}
});
});
describe('the 26Q reconciliation control', () => {
it.each(TDS_FYS)('balances for FY %s', (fy) => {
const s = tds26qSummary(fy, TDS_AS_AT);
// Deducted must equal deposited plus what is still within its due date.
// Anything else means tax has gone missing between the ledger and the
// Government, and the return must not be filed until it is found.
expect(s.difference).toBe(0);
expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit);
});
it('splits the tax between the standard and the no-PAN rate without loss', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted);
expect(s.tdsAtHigherRate).toBeGreaterThan(0);
});
it('splits interest into the deducted and the paid-gross halves', () => {
const s = tds26qSummary(CLOSED_FY, TDS_AS_AT);
expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid);
// The gross-paid half is the Form 121 disclosure and must not be zero here,
// or the report would never exercise that line.
expect(s.interestWithoutDeduction).toBeGreaterThan(0);
});
it('agrees with the interest report on the same year', () => {
const summary = tdsInterestSummary(filter());
expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds);
});
});
describe('the part-run current year', () => {
it('has postings only up to the business date', () => {
for (const deductee of tdsDeductees()) {
for (const account of deductee.accounts) {
for (const credit of account.credits) {
expect(credit.date <= TDS_AS_AT).toBe(true);
}
}
}
});
it('shows the closed quarter as settled and future obligations as not yet due', () => {
const rows = tdsRemittanceRows('2026-27', TDS_AS_AT);
// Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul.
expect(rows.some((r) => r.quarter === 1)).toBe(true);
// Q3 cannot appear: no interest has posted there and nothing settles there.
expect(rows.some((r) => r.quarter === 3)).toBe(false);
// Q4 *can* appear even in a part-run year — deposits on annual interest
// posting settle once, at year end — but that is a future obligation, so it
// must read as pending and must never be flagged overdue.
for (const row of rows.filter((r) => r.quarter === 4)) {
expect(row.depositedOn).toBeNull();
expect(row.overdue).toBe(false);
expect(row.defaultInterest).toBe(0);
}
});
});
+395
View File
@@ -0,0 +1,395 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* Aggregation for the two TDS reports.
*
* Every rupee figure here comes out of `tds-15gh.compute` — the reports do no
* tax arithmetic of their own. Writing it twice is how the register and the
* return end up disagreeing, so the engine is the single authority and this
* module only groups and totals what it returns.
*/
import {
DeclarationForm,
FY_TAX_CONFIG,
Quarter,
TdsMode,
TdsSchedule,
ageInFy,
buildSchedule,
collectTdsCredits,
declarationFormFor,
formKindFor,
fyOf,
isSeniorInFy,
isTdsApplicable,
isWellFormedPan,
lateDeductionInterest,
monthsOrPartBetween,
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees } from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
export interface TdsReportFilter {
fy: string;
/** '' means every branch. */
branchCode: string;
/** 0 means the whole year. */
quarter: 0 | Quarter;
/** '' means both declaration types. */
band: '' | 'GENERAL' | 'SENIOR';
/** Free text over name, CIF and PAN. */
search: string;
/** Only customers whose aggregate crossed the deduction trigger. */
crossedOnly: boolean;
/** Only customers sitting just below the trigger — the follow-up list. */
nearThresholdOnly: boolean;
}
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
// ───────────────────────── Report 1: interest ─────────────────────────
export interface TdsInterestRow {
deductee: TdsDeductee;
branchCode: string;
/** The form in force for the year — '121' from FY 2026-27. */
form: DeclarationForm;
/** Which deduction trigger applies. Age-based, even after the forms merged. */
band: 'GENERAL' | 'SENIOR';
/**
* The 15G/15H label. A form distinction only up to FY 2025-26; from FY 2026-27
* the two merged, so it duplicates `band` and is kept for pre-2026 years.
*/
kind: '15G' | '15H';
age: number;
/** Settlement timing, from the customer's deposit products. */
mode: TdsMode;
/** Interest on time deposits where this customer is the deductee, by quarter. */
quarters: Record<Quarter, number>;
/** Total of the above — the figure the deduction trigger is tested against. */
tdsApplicableInterest: number;
/** Savings interest, shown alongside but outside the charge on time deposits. */
exemptInterest: number;
/** Interest on deposits where somebody else is the first-named holder. */
jointHeldInterest: number;
threshold: number;
crossed: boolean;
/** The quarter the aggregate crossed the trigger, if it did. */
crossedInQuarter: Quarter | null;
/** How much more interest before the trigger bites. */
headroom: number;
nearThreshold: boolean;
schedule: TdsSchedule;
hasDeclaration: boolean;
declarationUin: string | null;
declarationAcceptedOn: string | null;
/** Tax the declaration kept out of the customer's pocket. */
tdsSaved: number;
/** Interest paid gross on the strength of the declaration — reportable in the return. */
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
}
function emptyQuarters(): Record<Quarter, number> {
return { 1: 0, 2: 0, 3: 0, 4: 0 };
}
/**
* One row per deductee for the selected year.
*
* Memoised on the year alone: building a row runs two schedules, and the getters
* that feed the template are read many times per change-detection pass.
*/
let rowCacheKey = '';
let rowCache: TdsInterestRow[] = [];
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
if (rowCacheKey === fy) {
return rowCache;
}
const rows: TdsInterestRow[] = [];
for (const deductee of tdsDeductees()) {
const ctx = {
fy,
dateOfBirth: deductee.dob,
pan: deductee.pan,
resident: true
};
// Rules 8 to 12 live in the engine: savings interest dropped, joint deposits
// attributed to the first-named holder, every branch aggregated.
const credits = collectTdsCredits(deductee.clientId, deductee.accounts, fy);
if (!credits.length && !deductee.accounts.length) {
continue;
}
const declaration = deductee.declarations[fy] ?? null;
const mode = overallModeFor(deductee);
const schedule = buildSchedule(mode, credits, { ...ctx, declaration });
const withoutDeclaration = declaration ? buildSchedule(mode, credits, { ...ctx, declaration: null }) : schedule;
const quarters = emptyQuarters();
for (const line of schedule.lines) {
quarters[line.quarter] = line.interest;
}
let exemptInterest = 0;
let jointHeldInterest = 0;
for (const account of deductee.accounts) {
const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
}
}
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
rows.push({
deductee,
branchCode: deductee.branchCode,
form: declarationFormFor(fy, deductee.dob),
band: isSeniorInFy(deductee.dob, fy) ? 'SENIOR' : 'GENERAL',
kind: formKindFor(deductee.dob, fy),
age: ageInFy(deductee.dob, fy),
mode,
quarters,
tdsApplicableInterest: schedule.totalInterest,
exemptInterest,
jointHeldInterest,
threshold: schedule.threshold,
crossed: schedule.totalInterest > schedule.threshold,
crossedInQuarter: crossedLine?.quarter ?? null,
headroom,
nearThreshold: headroom > 0 && headroom <= NEAR_THRESHOLD_BAND,
schedule,
hasDeclaration: !!declaration,
declarationUin: declaration?.uin ?? null,
declarationAcceptedOn: declaration?.acceptedOn ?? null,
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
rateApplied: schedule.rateApplied
});
}
rows.sort((a, b) => b.tdsApplicableInterest - a.tdsApplicableInterest);
rowCacheKey = fy;
rowCache = rows;
return rows;
}
/**
* When tax settles for a customer whose deposits post interest on different
* cycles. A quarterly deposit anywhere means a deduction falls due at the first
* quarter end, so that governs.
*/
function overallModeFor(deductee: TdsDeductee): TdsMode {
const applicable = deductee.accounts.filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === deductee.clientId);
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
export function tdsInterestRows(filter: TdsReportFilter): TdsInterestRow[] {
const needle = filter.search.trim().toLowerCase();
return tdsInterestRowsForFy(filter.fy).filter((row) => {
if (filter.branchCode && row.branchCode !== filter.branchCode) {
return false;
}
if (filter.band && row.band !== filter.band) {
return false;
}
if (filter.crossedOnly && !row.crossed) {
return false;
}
if (filter.nearThresholdOnly && !row.nearThreshold) {
return false;
}
// A quarter filter narrows to customers who actually had interest in it.
if (filter.quarter && !row.quarters[filter.quarter]) {
return false;
}
if (!needle) {
return true;
}
const d = row.deductee;
return (
d.name.toLowerCase().includes(needle) ||
d.customerId.toLowerCase().includes(needle) ||
d.pan.toLowerCase().includes(needle)
);
});
}
export interface TdsInterestSummary {
deductees: number;
/** Interest on time deposits — the base the charge applies to. */
applicableInterest: number;
/** Savings interest, outside the section. */
exemptInterest: number;
totalTds: number;
crossed: number;
nearThreshold: number;
declarations: number;
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary {
const rows = tdsInterestRows(filter);
const byQuarter = ([
1,
2,
3,
4
] as Quarter[]).map((quarter) => ({
quarter,
interest: rows.reduce((s, r) => s + r.quarters[quarter], 0),
tds: rows.reduce((s, r) => s + (r.schedule.lines.find((l) => l.quarter === quarter)?.tds ?? 0), 0)
}));
return {
deductees: rows.length,
applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0),
totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0),
crossed: rows.filter((r) => r.crossed).length,
nearThreshold: rows.filter((r) => r.nearThreshold).length,
declarations: rows.filter((r) => r.hasDeclaration).length,
tdsSaved: rows.reduce((s, r) => s + r.tdsSaved, 0),
paidWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
noPan: rows.filter((r) => !r.panOnRecord).length,
byQuarter
};
}
// ─────────────────────── Report 2: remittance ───────────────────────
export interface TdsRemittanceRow extends TdsChallan {
/** Statutory due date has passed with nothing deposited. */
overdue: boolean;
/** Deposited, but after the due date. */
late: boolean;
/** Months or part months of delay. */
delayMonths: number;
/** Interest charged for depositing late. */
defaultInterest: number;
/** Return the quarter's deduction is reported in. */
returnDueDate: string;
}
const RETURN_DUE: Record<Quarter, string> = {
1: '31 Jul',
2: '31 Oct',
3: '31 Jan',
4: '31 May'
};
/** Tax deducted per quarter, from the engine, for one year. */
export function deductedByQuarter(fy: string): Record<number, number> {
const out: Record<number, number> = { 1: 0, 2: 0, 3: 0, 4: 0 };
for (const row of tdsInterestRowsForFy(fy)) {
for (const line of row.schedule.lines) {
out[line.quarter] += line.tds;
}
}
return out;
}
export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
const deducted: Record<string, Record<number, number>> = {};
for (const year of Object.keys(FY_TAX_CONFIG)) {
deducted[year] = deductedByQuarter(year);
}
const challans = tdsChallans(deducted)[fy] ?? [];
return challans.map((c) => {
const overdue = !c.depositedOn && c.dueDate < asAt;
const late = !!c.depositedOn && c.depositedOn > c.dueDate;
// While a payment is overdue the clock runs to today; once paid it stops on
// the payment date.
const settledOn = c.depositedOn ?? asAt;
const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0;
const defaultInterest =
late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0;
return {
...c,
overdue,
late,
delayMonths,
defaultInterest,
returnDueDate: RETURN_DUE[c.quarter]
};
});
}
/**
* The year-end position — what the quarterly TDS return has to reconcile to.
*
* `difference` is the control figure: tax the engine says was deducted, less what
* actually reached the Government. Anything other than zero, once the pending
* challans are settled, means something is wrong and must be found before filing.
*/
export interface Tds26qSummary {
fy: string;
deductees: number;
totalInterestPaid: number;
interestWithDeduction: number;
/** Paid gross against a declaration — a mandatory disclosure in the return. */
interestWithoutDeduction: number;
tdsAtStandardRate: number;
tdsAtHigherRate: number;
totalDeducted: number;
totalDeposited: number;
pendingDeposit: number;
/** Deducted less deposited less still-pending. Must be zero. */
difference: number;
defaultInterest: number;
lateChallans: number;
overdueChallans: number;
}
export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
const rows = tdsInterestRowsForFy(fy);
const challans = tdsRemittanceRows(fy, asAt);
const standard = rows.filter((r) => r.panOnRecord);
const higher = rows.filter((r) => !r.panOnRecord);
const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0);
const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0);
const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0);
return {
fy,
deductees: rows.length,
totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0),
interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0),
interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0),
tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0),
tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0),
totalDeducted,
totalDeposited,
pendingDeposit,
difference: Math.round(totalDeducted - totalDeposited - pendingDeposit),
defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0),
lateChallans: challans.filter((c) => c.late).length,
overdueChallans: challans.filter((c) => c.overdue).length
};
}
+546
View File
@@ -0,0 +1,546 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/* Shared by the interest report and the remittance report. */
.tdsr {
--tdsr-line: #e5e7eb;
--tdsr-bg: #f9fafb;
--tdsr-ink: #111827;
--tdsr-muted: #6b7280;
--tdsr-blue: #1d4ed8;
--tdsr-green: #15803d;
--tdsr-amber: #b45309;
padding: 20px 24px 40px;
font-size: 13px;
color: var(--tdsr-ink);
}
/* ── Header ── */
.tdsr__head {
display: flex;
flex-wrap: wrap;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
margin-bottom: 18px;
}
.tdsr__head-l {
display: flex;
gap: 14px;
align-items: flex-start;
}
.tdsr__head-r {
display: flex;
gap: 12px;
align-items: flex-end;
}
.tdsr__head-ic {
font-size: 22px;
color: var(--tdsr-blue);
margin-top: 3px;
}
.tdsr__title {
margin: 0;
font-size: 19px;
font-weight: 800;
}
.tdsr__sub {
margin-top: 4px;
color: var(--tdsr-muted);
font-size: 12px;
line-height: 1.5;
}
.tdsr__btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 9px 15px;
border: 1px solid var(--tdsr-line);
border-radius: 7px;
background: #fff;
font-size: 12.5px;
font-weight: 700;
color: var(--tdsr-ink);
cursor: pointer;
&:hover:not(:disabled) {
background: var(--tdsr-bg);
}
&:disabled {
opacity: 0.55;
cursor: default;
}
}
/* ── Banners ── */
.tdsr__banner {
display: flex;
gap: 11px;
align-items: flex-start;
margin-bottom: 14px;
padding: 13px 15px;
border-radius: 9px;
border-left: 4px solid var(--tdsr-blue);
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
line-height: 1.55;
fa-icon {
flex-shrink: 0;
margin-top: 1px;
color: var(--tdsr-blue);
}
&--ok {
border-left-color: var(--tdsr-green);
background: color-mix(in srgb, var(--tdsr-green) 7%, transparent);
fa-icon {
color: var(--tdsr-green);
}
}
&--warn {
border-left-color: var(--tdsr-amber);
background: color-mix(in srgb, var(--tdsr-amber) 9%, transparent);
fa-icon {
color: var(--tdsr-amber);
}
}
}
/* ── KPI strip ── */
.tdsr__kpis {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(165px, 1fr));
gap: 10px;
margin-bottom: 16px;
}
.tdsr__kpi {
padding: 13px 15px;
border: 1px solid var(--tdsr-line);
border-left: 3px solid var(--tdsr-blue);
border-radius: 9px;
background: #fff;
text-align: left;
&--ok {
border-left-color: var(--tdsr-green);
}
&--warn {
border-left-color: var(--tdsr-amber);
}
&--muted {
border-left-color: var(--tdsr-line);
}
/* The near-threshold tile doubles as the button that filters to it. */
&--action {
border-left-color: var(--tdsr-green);
cursor: pointer;
font: inherit;
color: inherit;
&:hover {
background: var(--tdsr-bg);
}
}
}
.tdsr__kpi-n {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.tdsr__kpi-l {
margin-top: 3px;
font-size: 11px;
color: var(--tdsr-muted);
line-height: 1.4;
}
/* ── Filters ── */
.tdsr__filters {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: flex-end;
margin-bottom: 16px;
padding: 14px 16px;
border: 1px solid var(--tdsr-line);
border-radius: 9px;
background: #fff;
}
.tdsr__filter {
display: flex;
flex-direction: column;
gap: 5px;
> span {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tdsr-muted);
}
select,
input {
min-width: 150px;
padding: 7px 10px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
font-size: 13px;
color: inherit;
}
&--wide input {
min-width: 230px;
}
}
.tdsr__check {
display: inline-flex;
gap: 7px;
align-items: center;
padding-bottom: 8px;
font-size: 12.5px;
cursor: pointer;
}
.tdsr__clear {
display: inline-flex;
align-items: center;
gap: 6px;
margin-bottom: 4px;
padding: 7px 12px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: var(--tdsr-bg);
font-size: 12px;
font-weight: 700;
color: var(--tdsr-muted);
cursor: pointer;
}
/* ── Cards ── */
.tdsr__card {
margin-bottom: 18px;
border: 1px solid var(--tdsr-line);
border-radius: 10px;
background: #fff;
overflow: hidden;
}
.tdsr__card-head {
display: flex;
align-items: center;
gap: 9px;
padding: 12px 16px;
background: var(--tdsr-bg);
border-bottom: 1px solid var(--tdsr-line);
font-weight: 800;
font-size: 12.5px;
fa-icon {
color: var(--tdsr-muted);
}
}
.tdsr__foot {
padding: 11px 16px;
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
color: var(--tdsr-muted);
font-size: 11.5px;
line-height: 1.55;
}
.tdsr__empty {
padding: 26px 16px;
text-align: center;
color: var(--tdsr-muted);
fa-icon {
margin-right: 6px;
color: var(--tdsr-blue);
}
}
/* ── Quarter strip ── */
.tdsr__qstrip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
}
.tdsr__qcell {
padding: 14px 16px;
border-right: 1px solid var(--tdsr-line);
&:last-child {
border-right: 0;
}
}
.tdsr__qcell-l {
font-size: 10.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--tdsr-muted);
}
.tdsr__qcell-n {
margin-top: 4px;
font-size: 17px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.tdsr__qcell-s {
margin-top: 2px;
font-size: 11.5px;
color: var(--tdsr-muted);
}
/* ── Tables ── */
.tdsr__table-wrap {
overflow-x: auto;
}
.tdsr__table {
width: 100%;
border-collapse: collapse;
font-size: 12.5px;
th {
text-align: left;
padding: 11px 14px;
background: #fff;
border-bottom: 1px solid var(--tdsr-line);
color: var(--tdsr-muted);
font-weight: 700;
font-size: 10.5px;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
}
td {
padding: 11px 14px;
border-bottom: 1px solid #f3f4f6;
vertical-align: top;
}
tr:last-child td {
border-bottom: 0;
}
.num {
text-align: right;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.small {
font-size: 11.5px;
}
.muted {
color: var(--tdsr-muted);
}
.col-actions {
width: 44px;
text-align: right;
}
&--inner {
border: 1px solid var(--tdsr-line);
border-radius: 8px;
overflow: hidden;
margin-bottom: 10px;
}
}
.tdsr__row--warn > td {
background: color-mix(in srgb, var(--tdsr-amber) 7%, transparent);
}
.tdsr__row--near > td {
background: color-mix(in srgb, var(--tdsr-green) 5%, transparent);
}
.tdsr__row--detail > td {
background: var(--tdsr-bg);
padding: 16px;
}
.tdsr__name {
font-weight: 700;
}
.tdsr__ok {
color: var(--tdsr-green);
}
.tdsr__warn-text {
color: var(--tdsr-amber);
}
.tdsr__icon-btn {
padding: 5px 8px;
border: 1px solid var(--tdsr-line);
border-radius: 6px;
background: #fff;
color: var(--tdsr-muted);
cursor: pointer;
&:hover {
background: var(--tdsr-bg);
}
}
.tdsr__note {
display: flex;
gap: 9px;
align-items: flex-start;
margin-bottom: 11px;
padding: 10px 12px;
border-radius: 8px;
border-left: 3px solid var(--tdsr-blue);
background: color-mix(in srgb, var(--tdsr-blue) 6%, transparent);
font-size: 12px;
line-height: 1.5;
fa-icon {
flex-shrink: 0;
margin-top: 2px;
color: var(--tdsr-blue);
}
}
.tdsr__meta {
display: flex;
flex-wrap: wrap;
gap: 18px;
font-size: 11.5px;
color: var(--tdsr-muted);
}
/* ── Ledger (Part B of the remittance report) ── */
.tdsr__ledger-row {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 11px 16px;
border-bottom: 1px solid #f3f4f6;
line-height: 1.5;
&:last-child {
border-bottom: 0;
}
.num {
font-variant-numeric: tabular-nums;
white-space: nowrap;
font-weight: 600;
}
em {
display: block;
font-size: 10.5px;
color: var(--tdsr-amber);
font-style: normal;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
}
&--indent {
padding-left: 36px;
color: var(--tdsr-muted);
}
&--sep {
border-top: 1px solid var(--tdsr-line);
}
&--total {
border-top: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
font-weight: 800;
}
/* The reconciliation control. Green when zero, red when not. */
&--control {
background: color-mix(in srgb, var(--tdsr-green) 8%, transparent);
font-weight: 800;
color: var(--tdsr-green);
}
&--bad {
background: color-mix(in srgb, var(--tdsr-amber) 10%, transparent);
color: var(--tdsr-amber);
}
}
/* ── Pills ── */
.tdsr__pill {
display: inline-flex;
align-items: center;
padding: 2px 9px;
border-radius: 999px;
border: 1px solid var(--tdsr-line);
background: var(--tdsr-bg);
color: var(--tdsr-muted);
font-size: 10.5px;
font-weight: 700;
white-space: nowrap;
& + & {
margin-left: 5px;
}
&--ok {
border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
background: color-mix(in srgb, var(--tdsr-green) 10%, transparent);
color: var(--tdsr-green);
}
&--warn {
border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
background: color-mix(in srgb, var(--tdsr-amber) 12%, transparent);
color: var(--tdsr-amber);
}
&--near {
border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
background: color-mix(in srgb, var(--tdsr-blue) 8%, transparent);
color: var(--tdsr-blue);
}
}
@@ -0,0 +1,466 @@
/**
* Copyright since 2025 Mifos Initiative
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
/**
* The three things the bank actually has to file or hand over.
*
* 1. The consolidated statement of declarations received — monthly from
* FY 2026-27, due on the 7th of the following month, carrying the UIN the
* branch allotted to each Form 121.
* 2. The deductee annexure of the quarterly return (Form 140), which needs the
* UIN against every payee paid gross so the department can tie a
* nil-deduction back to a declaration.
* 3. The certificate issued to each deductee (Form 131), which is what the
* customer uses to claim credit in their own return.
*
* All figures come from the TDS engine. Nothing here recomputes tax — a return
* that disagrees with the register is worse than no return at all.
*/
import {
FY_TAX_CONFIG,
Quarter,
buildUin,
declarationStatementDueDate,
isWellFormedPan,
normaliseFy,
quarterEnd,
quarterOf
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
import { TDS_AS_AT } from './tds-reports-data';
import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute';
/** The bank's own deductor particulars, which head every filing. */
export interface DeductorParticulars {
tan: string;
pan: string;
name: string;
branchAddress: string;
responsiblePerson: string;
designation: string;
}
/**
* Placeholder particulars for the demo book. In a live install these come from
* the organisation's tax registration, not from a constant.
*/
export const DEDUCTOR: DeductorParticulars = {
tan: 'MUMC12345E',
pan: 'AAACC1234C',
name: 'CBS Nexora Co-operative Bank Ltd.',
branchAddress: 'Head Office, Mumbai 400001',
responsiblePerson: 'Chief Financial Officer',
designation: 'Principal Officer'
};
// ───────────────── 1. Consolidated statement of declarations ─────────────────
export interface DeclarationStatementLine {
/** Running number within the statement. */
serial: number;
uin: string;
payeeName: string;
pan: string;
customerId: string;
branchCode: string;
/** Date the branch accepted the declaration. */
acceptedOn: string;
/**
* Income the declaration covers, as estimated when it was accepted. The
* statement reports the amount the payer expects to pay gross.
*/
estimatedIncome: number;
/** Interest actually paid without deduction so far. */
paidWithoutDeduction: number;
/** Whether the declaration would survive scrutiny on the branch's own record. */
eligible: boolean;
}
export interface DeclarationStatementPeriod {
/** 'MONTHLY' periods are keyed '2026-04'; quarterly ones 'Q1'. */
key: string;
label: string;
/** Statutory upload deadline for this period. */
dueDate: string;
overdue: boolean;
lines: DeclarationStatementLine[];
totalPaidWithoutDeduction: number;
}
export interface DeclarationStatement {
fy: string;
form: string;
rule: string;
frequency: 'MONTHLY' | 'QUARTERLY';
periods: DeclarationStatementPeriod[];
totalDeclarations: number;
/** Declarations on record that the branch could not lawfully have accepted. */
ineligibleCount: number;
/** Declarations with no UIN allotted — these cannot be filed at all. */
missingUinCount: number;
}
function monthLabel(key: string): string {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec'
];
return `${months[Number(key.slice(5, 7)) - 1]} ${key.slice(0, 4)}`;
}
/**
* Groups every declaration on record for the year into the periods the payer has
* to upload, and flags the ones that are already past their due date.
*
* A declaration with no UIN is counted separately rather than filed with a blank:
* the portal rejects the line, and a silently dropped row is how a bank ends up
* reporting fewer declarations than it holds.
*/
export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const declared = tdsInterestRowsForFy(year).filter((r) => r.hasDeclaration && r.declarationAcceptedOn);
const buckets = new Map<string, DeclarationStatementLine[]>();
let serial = 0;
let ineligibleCount = 0;
let missingUinCount = 0;
for (const row of declared) {
const acceptedOn = row.declarationAcceptedOn as string;
const key =
cfg.declarationStatementFrequency === 'MONTHLY' ? acceptedOn.slice(0, 7) : `Q${quarterOf(acceptedOn)}`;
if (!row.declarationUin) {
missingUinCount++;
}
// The branch cannot compute income earned elsewhere, so eligibility here is
// only what the register can see: a valid PAN on an individual.
const eligible = isWellFormedPan(row.deductee.pan);
if (!eligible) {
ineligibleCount++;
}
const line: DeclarationStatementLine = {
serial: ++serial,
uin: row.declarationUin ?? '',
payeeName: row.deductee.name,
pan: row.deductee.pan,
customerId: row.deductee.customerId,
branchCode: row.branchCode,
acceptedOn,
estimatedIncome: row.tdsApplicableInterest,
paidWithoutDeduction: row.paidWithoutDeduction,
eligible
};
const list = buckets.get(key);
if (list) {
list.push(line);
} else {
buckets.set(key, [line]);
}
}
const periods: DeclarationStatementPeriod[] = [...buckets.entries()]
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([
key,
lines
]) => {
const dueDate = declarationStatementDueDate(lines[0].acceptedOn, year);
return {
key,
label: cfg.declarationStatementFrequency === 'MONTHLY' ? monthLabel(`${key}-01`) : key,
dueDate,
overdue: dueDate < asAt,
lines,
totalPaidWithoutDeduction: lines.reduce((s, l) => s + l.paidWithoutDeduction, 0)
};
});
return {
fy: year,
form: cfg.declarationForm === '121' ? 'Form 121' : 'Form 15G / 15H',
rule: cfg.declarationRule,
frequency: cfg.declarationStatementFrequency,
periods,
totalDeclarations: declared.length,
ineligibleCount,
missingUinCount
};
}
/**
* Allots UINs to declarations that do not have one.
*
* Sequence numbers continue from the highest already in use for the year, so a
* re-run does not renumber declarations that have already been filed.
*/
export function allotMissingUins(rows: TdsInterestRow[], fy: string, tan: string = DEDUCTOR.tan): Map<string, string> {
const out = new Map<string, string>();
let next = rows.reduce((max, r) => {
const seq = r.declarationUin ? Number(r.declarationUin.slice(1, 10)) : 0;
return Number.isFinite(seq) && seq > max ? seq : max;
}, 0);
for (const row of rows) {
if (row.hasDeclaration && !row.declarationUin) {
out.set(row.deductee.customerId, buildUin(++next, fy, tan));
}
}
return out;
}
// ──────────────────── 2. Deductee annexure of the return ────────────────────
/** One deductee line in the quarterly return's annexure. */
export interface ReturnAnnexureLine {
serial: number;
payeeName: string;
pan: string;
/** 'Individual' or 'HUF', from the fourth character of the PAN. */
payeeType: string;
/** Amount paid or credited in the quarter. */
amountPaid: number;
/** Tax deducted in the quarter. */
taxDeducted: number;
rate: number;
/**
* Reason the payer deducted nothing or less than the standard rate.
* Blank where tax was deducted normally.
*/
remark: string;
/** UIN of the declaration, required wherever the remark cites one. */
uin: string;
branchCode: string;
}
export interface ReturnAnnexure {
fy: string;
quarter: Quarter;
form: string;
rule: string;
section: string;
/** Last day of the quarter the return covers. */
periodEnd: string;
dueDate: string;
lines: ReturnAnnexureLine[];
totalPaid: number;
totalDeducted: number;
/** Lines carrying a nil-deduction remark — these need a UIN. */
nilDeductionLines: number;
/** Nil-deduction lines with no UIN. The portal will reject these. */
missingUinLines: number;
}
const RETURN_DUE_DATE: Record<Quarter, (startYear: number) => string> = {
1: (y) => `${y}-07-31`,
2: (y) => `${y}-10-31`,
3: (y) => `${y + 1}-01-31`,
4: (y) => `${y + 1}-05-31`
};
/**
* Remark codes for a nil or lower deduction.
*
* The department's file specification assigns a letter per reason. The letters
* have been revised more than once, so they live here as named constants and
* must be checked against the current specification before a file is generated —
* a wrong code fails validation at upload, or worse, passes and misreports.
*/
export const NIL_DEDUCTION_REMARK = {
/** Payee filed a declaration for receipt without deduction. */
DECLARATION: 'B',
/** Aggregate stayed within the threshold, so nothing was deductible. */
BELOW_THRESHOLD: 'Y',
/** Higher rate applied because PAN was not furnished. */
NO_PAN: 'C'
};
function payeeTypeOf(pan: string): string {
const p = (pan || '').toUpperCase();
if (!isWellFormedPan(p)) {
return 'Unknown';
}
return p[3] === 'H' ? 'HUF' : p[3] === 'P' ? 'Individual' : 'Entity';
}
/**
* The deductee annexure for one quarter.
*
* Every payee who was paid interest in the quarter appears, whether or not tax
* came off — a payee paid gross against a declaration is precisely what the
* department cross-checks, so omitting them is the error to avoid.
*/
export function returnAnnexure(fy: string, quarter: Quarter): ReturnAnnexure {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const startYear = Number(year.slice(0, 4));
const lines: ReturnAnnexureLine[] = [];
let serial = 0;
for (const row of tdsInterestRowsForFy(year)) {
const line = row.schedule.lines.find((l) => l.quarter === quarter);
const amountPaid = line?.interest ?? 0;
const taxDeducted = line?.tds ?? 0;
if (amountPaid === 0 && taxDeducted === 0) {
continue;
}
// The remark explains why the deduction is not simply rate × amount.
let remark = '';
if (taxDeducted <= 0 && amountPaid > 0) {
if (row.hasDeclaration && line?.declarationActive) {
remark = NIL_DEDUCTION_REMARK.DECLARATION;
} else if (!row.crossed) {
remark = NIL_DEDUCTION_REMARK.BELOW_THRESHOLD;
}
} else if (taxDeducted > 0 && !row.panOnRecord) {
remark = NIL_DEDUCTION_REMARK.NO_PAN;
}
lines.push({
serial: ++serial,
payeeName: row.deductee.name,
pan: row.deductee.pan,
payeeType: payeeTypeOf(row.deductee.pan),
amountPaid,
taxDeducted,
rate: row.rateApplied,
remark,
uin: row.declarationUin ?? '',
branchCode: row.branchCode
});
}
const nilDeduction = lines.filter((l) => l.remark === NIL_DEDUCTION_REMARK.DECLARATION);
return {
fy: year,
quarter,
form: cfg.tdsStatementForm,
rule: cfg.tdsStatementRule,
section: cfg.interestTdsSection,
periodEnd: quarterEnd(quarter, year),
dueDate: RETURN_DUE_DATE[quarter](startYear),
lines,
totalPaid: lines.reduce((s, l) => s + l.amountPaid, 0),
totalDeducted: lines.reduce((s, l) => s + l.taxDeducted, 0),
nilDeductionLines: nilDeduction.length,
missingUinLines: nilDeduction.filter((l) => !l.uin).length
};
}
// ───────────────────── 3. Certificate for the deductee ─────────────────────
export interface TdsCertificateQuarterLine {
quarter: Quarter;
label: string;
amountPaid: number;
taxDeducted: number;
/** Tax the bank has actually remitted for this quarter. */
taxDeposited: number;
}
export interface TdsCertificate {
fy: string;
form: string;
section: string;
/** Certificate number, unique per deductee per year. */
certificateNo: string;
deductor: DeductorParticulars;
payeeName: string;
pan: string;
customerId: string;
branchCode: string;
lines: TdsCertificateQuarterLine[];
totalPaid: number;
totalDeducted: number;
/** Interest paid gross against a declaration, disclosed on the certificate. */
paidWithoutDeduction: number;
declarationUin: string | null;
rate: number;
/** A certificate cannot be issued without a valid PAN to issue it against. */
issuable: boolean;
blockingReason: string | null;
}
/**
* The TDS certificate for one deductee.
*
* Quarterly deposits are taken as equal to the quarter's deduction: the branch
* remits what it deducts, and the remittance report is where any gap between the
* two is surfaced. Wiring the certificate to actual challans is the right next
* step once challan records come from the backend rather than the seed.
*/
export function tdsCertificate(row: TdsInterestRow, fy: string): TdsCertificate {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
throw new Error(`No tax configuration for FY ${year}`);
}
const lines: TdsCertificateQuarterLine[] = row.schedule.lines.map((l) => ({
quarter: l.quarter,
label: l.label,
amountPaid: l.interest,
taxDeducted: l.tds,
taxDeposited: Math.max(0, l.tds)
}));
const issuable = isWellFormedPan(row.deductee.pan);
return {
fy: year,
form: cfg.certificateForm,
section: cfg.interestTdsSection,
certificateNo: `${DEDUCTOR.tan}/${uinSafe(year)}/${row.deductee.customerId}`,
deductor: DEDUCTOR,
payeeName: row.deductee.name,
pan: row.deductee.pan,
customerId: row.deductee.customerId,
branchCode: row.branchCode,
lines,
totalPaid: row.tdsApplicableInterest,
totalDeducted: row.schedule.totalTds,
paidWithoutDeduction: row.paidWithoutDeduction,
declarationUin: row.declarationUin,
rate: row.rateApplied,
issuable,
blockingReason: issuable
? null
: `No valid PAN on record. A certificate cannot be issued without one — the deductee would be unable to claim credit.`
};
}
function uinSafe(fy: string): string {
return normaliseFy(fy).replace('-', '');
}
/** Certificates for every deductee who had tax deducted in the year. */
export function tdsCertificates(fy: string): TdsCertificate[] {
return tdsInterestRowsForFy(normaliseFy(fy))
.filter((r) => r.schedule.totalTds > 0 || r.paidWithoutDeduction > 0)
.map((r) => tdsCertificate(r, fy));
}