-
+
diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts
index d6410f748..c58b0a4a0 100644
--- a/src/app/core/http/cache.interceptor.ts
+++ b/src/app/core/http/cache.interceptor.ts
@@ -36,7 +36,15 @@ export class CacheInterceptor implements HttpInterceptor {
'/rbireserveconfig',
'/tdsconfig',
'/tds/deposit-interest',
- '/savingsinterestpostingreports'
+ '/savingsinterestpostingreports',
+ // Cash in vaults and drawers changes with every transaction; a cached teller list or position
+ // kept showing a branch's old tellers and figures at day close.
+ '/tellers',
+ '/cashreports',
+ '/cashreconciliation',
+ '/cashsettlement',
+ '/cashtransfer',
+ '/branchday'
];
private isCacheable(url: string): boolean {
diff --git a/src/app/interest-posting-report/interest-posting-report.component.html b/src/app/interest-posting-report/interest-posting-report.component.html
index 6c4c92edb..b830e7b92 100644
--- a/src/app/interest-posting-report/interest-posting-report.component.html
+++ b/src/app/interest-posting-report/interest-posting-report.component.html
@@ -22,9 +22,134 @@
+
+ @if (isHeadOffice) {
+
+
+
+
+ @if (tab === 'others') {
+
+
+
+ Each branch generates and approves its own reports. The head office can act here only for a branch that has
+ not opened the day, and only once every other open branch has closed. Anything it approves is marked (HO).
+
+
+ }
+ }
+
+ @if (showPreview) {
+
+
+
+
Interest preview — earned up to {{ previewAsOf }}
+
+ What each account would be paid if interest were posted today. View only — nothing is saved, approved or
+ posted.
+
+
+
+ Total so far
+ ₹{{ previewTotal | number: '1.2-2' }}
+ {{ previewRows.length }} account(s)
+
@@ -114,9 +243,12 @@
}
@if (canApprove(r)) {
}
+ @if (!r.canAct && r.blockedReason && (r.status === 'GENERATED' || r.status === 'APPROVED')) {
+ {{ r.blockedReason }}
+ }
} @empty {
diff --git a/src/app/interest-posting-report/interest-posting-report.component.scss b/src/app/interest-posting-report/interest-posting-report.component.scss
index 6adbcaa77..a4d744927 100644
--- a/src/app/interest-posting-report/interest-posting-report.component.scss
+++ b/src/app/interest-posting-report/interest-posting-report.component.scss
@@ -274,3 +274,54 @@
font-weight: 600;
color: var(--i-amber);
}
+
+// Preview: refresh / export / close, above the totals.
+.ipr-preview-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+ padding: 0 16px 10px;
+}
+
+.ipr-tabs {
+ display: flex;
+ gap: 4px;
+ margin-bottom: 12px;
+ border-bottom: 1px solid var(--i-g200);
+}
+
+.ipr-tab {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 8px 14px;
+ margin-bottom: -1px;
+ font-size: 13px;
+ font-weight: 600;
+ color: var(--i-g500);
+ cursor: pointer;
+ background: transparent;
+ border: 1px solid transparent;
+ border-radius: 6px 6px 0 0;
+
+ &.is-active {
+ color: var(--i-navy);
+ background: #fff;
+ border-color: var(--i-g200);
+ border-bottom-color: #fff;
+ }
+}
+
+.ipr-alert--info {
+ background: var(--i-blue-l);
+ color: var(--i-blue);
+}
+
+.ipr-blocked {
+ display: inline-block;
+ max-width: 320px;
+ font-size: 11px;
+ line-height: 1.35;
+ color: var(--i-g500);
+ white-space: normal;
+}
diff --git a/src/app/interest-posting-report/interest-posting-report.component.ts b/src/app/interest-posting-report/interest-posting-report.component.ts
index d7a708060..c4fd3f1ad 100644
--- a/src/app/interest-posting-report/interest-posting-report.component.ts
+++ b/src/app/interest-posting-report/interest-posting-report.component.ts
@@ -11,11 +11,14 @@ import { FormsModule } from '@angular/forms';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { SettingsService } from 'app/settings/settings.service';
+import { exportWorkbook } from '../cashier/cashier-export';
import {
InterestPostingDue,
InterestPostingReport,
InterestPostingReportDetail,
- InterestPostingReportService
+ InterestPostingReportService,
+ InterestPreviewRow,
+ ReportScope
} from './interest-posting-report.service';
/**
@@ -51,15 +54,48 @@ export class InterestPostingReportComponent implements OnInit {
/** Products with interest due and no report yet — the noon job ran before they had anything due, or failed. */
due: InterestPostingDue[] = [];
+ /** 'own' — the user's own branch; 'others' — every other branch, a head-office-only tab. */
+ tab: ReportScope = 'own';
+ isHeadOffice = false;
+ myBranch = '';
+
ngOnInit(): void {
this.postingDate = this.businessDateIso();
+ this.reportService.context().subscribe({
+ next: (ctx) => {
+ this.isHeadOffice = !!ctx?.headOffice;
+ this.myBranch = ctx?.officeName ?? '';
+ },
+ error: () => (this.isHeadOffice = false)
+ });
this.load();
}
+ switchTab(tab: ReportScope): void {
+ if (this.tab === tab) {
+ return;
+ }
+ this.tab = tab;
+ this.selected = null;
+ this.notice = '';
+ this.load();
+ }
+
+ /** Acting for another branch is the head office's exception, so it is spelt out before it happens. */
+ private confirmOnBehalf(officeName: string, action: string): boolean {
+ if (this.tab !== 'others') {
+ return true;
+ }
+ return confirm(
+ `${officeName} has not opened today. You are about to ${action} its interest posting report on its behalf, ` +
+ 'from the head office. Continue?'
+ );
+ }
+
load(): void {
this.loading = true;
this.errorMessage = '';
- this.reportService.list(this.postingDate || undefined).subscribe({
+ this.reportService.list(this.postingDate || undefined, undefined, undefined, this.tab).subscribe({
next: (reports) => {
this.reports = reports ?? [];
this.loading = false;
@@ -75,13 +111,16 @@ export class InterestPostingReportComponent implements OnInit {
this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
}
});
- this.reportService.dueWithoutReport(this.postingDate || undefined).subscribe({
+ this.reportService.dueWithoutReport(this.postingDate || undefined, this.tab).subscribe({
next: (due) => (this.due = due ?? []),
error: () => (this.due = [])
});
}
generate(item: InterestPostingDue): void {
+ if (!this.confirmOnBehalf(item.officeName, 'generate')) {
+ return;
+ }
this.busy = true;
this.notice = '';
this.errorMessage = '';
@@ -111,7 +150,8 @@ export class InterestPostingReportComponent implements OnInit {
if (
!confirm(
`Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
- )
+ ) ||
+ !this.confirmOnBehalf(report.officeName, 'regenerate')
) {
return;
}
@@ -138,7 +178,8 @@ export class InterestPostingReportComponent implements OnInit {
!confirm(
`Approve interest of ₹${report.totalInterest} across ${report.accountCount} account(s) for ${report.productName}? ` +
'It will be posted when the branch closes the day.'
- )
+ ) ||
+ !this.confirmOnBehalf(report.officeName, 'approve')
) {
return;
}
@@ -177,8 +218,165 @@ export class InterestPostingReportComponent implements OnInit {
);
}
+ /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
get pendingCount(): number {
- return this.reports.filter((r) => r.status === 'GENERATED').length;
+ return this.tab === 'own' ? this.reports.filter((r) => r.status === 'GENERATED').length : 0;
+ }
+
+ // ── Preview: what each account has earned so far, as if posted today. View only. ──
+
+ showPreview = false;
+ previewLoading = false;
+ previewRows: InterestPreviewRow[] = [];
+ previewAsOf = '';
+
+ openPreview(): void {
+ this.showPreview = true;
+ this.previewLoading = true;
+ this.errorMessage = '';
+ this.reportService.preview().subscribe({
+ next: (rows) => {
+ this.previewRows = rows ?? [];
+ this.previewAsOf = this.previewRows[0]?.asOf ?? this.businessDateIso();
+ this.previewLoading = false;
+ },
+ error: (err) => {
+ this.previewLoading = false;
+ this.previewRows = [];
+ this.errorMessage = this.messageOf(err, 'Could not load the interest preview.');
+ }
+ });
+ }
+
+ closePreview(): void {
+ this.showPreview = false;
+ }
+
+ get previewTotal(): number {
+ return this.previewRows.reduce((sum, r) => sum + Number(r.interest || 0), 0);
+ }
+
+ /** Totals by branch and product, for the summary above the account list and the export. */
+ get previewGroups(): {
+ officeName: string;
+ productName: string;
+ postingDate: string;
+ accounts: number;
+ interest: number;
+ }[] {
+ const map = new Map<
+ string,
+ { officeName: string; productName: string; postingDate: string; accounts: number; interest: number }
+ >();
+ this.previewRows.forEach((r) => {
+ const key = `${r.officeName}|${r.productName}`;
+ const g = map.get(key) ?? {
+ officeName: r.officeName,
+ productName: r.productName,
+ postingDate: r.postingDate,
+ accounts: 0,
+ interest: 0
+ };
+ g.accounts += 1;
+ g.interest += Number(r.interest || 0);
+ map.set(key, g);
+ });
+ return [...map.values()];
+ }
+
+ exportPreview(): void {
+ const stamp = `Interest earned to ${this.previewAsOf} — preview, not posted`;
+ exportWorkbook(
+ [
+ {
+ sheetName: 'By account',
+ title: 'Savings Interest Preview',
+ subtitle: stamp,
+ headers: [
+ 'Branch',
+ 'Product',
+ 'Account No',
+ 'Customer',
+ 'Balance',
+ 'Rate %',
+ 'Cycle Start',
+ 'Posting Date',
+ 'Interest So Far'
+ ],
+ widths: [
+ 18,
+ 22,
+ 20,
+ 26,
+ 16,
+ 8,
+ 12,
+ 12,
+ 16
+ ],
+ rows: [
+ ...this.previewRows.map((r) => [
+ r.officeName,
+ r.productName,
+ r.accountNo,
+ r.clientName,
+ Number(r.balance || 0),
+ Number(r.rate || 0),
+ r.cycleStart,
+ r.postingDate,
+ Number(r.interest || 0)
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ '',
+ this.previewTotal
+ ]
+ ]
+ },
+ {
+ sheetName: 'By branch & product',
+ title: 'Savings Interest Preview — Totals',
+ subtitle: stamp,
+ headers: [
+ 'Branch',
+ 'Product',
+ 'Posting Date',
+ 'Accounts',
+ 'Interest So Far'
+ ],
+ widths: [
+ 18,
+ 22,
+ 12,
+ 10,
+ 16
+ ],
+ rows: [
+ ...this.previewGroups.map((g) => [
+ g.officeName,
+ g.productName,
+ g.postingDate,
+ g.accounts,
+ g.interest
+ ]),
+ [
+ 'TOTAL',
+ '',
+ '',
+ this.previewRows.length,
+ this.previewTotal
+ ]
+ ]
+ }
+ ],
+ `savings-interest-preview-${this.previewAsOf}`
+ );
}
private businessDateIso(): string {
diff --git a/src/app/interest-posting-report/interest-posting-report.service.ts b/src/app/interest-posting-report/interest-posting-report.service.ts
index 398dd2f8b..7d2466e49 100644
--- a/src/app/interest-posting-report/interest-posting-report.service.ts
+++ b/src/app/interest-posting-report/interest-posting-report.service.ts
@@ -12,6 +12,9 @@ import { Observable } from 'rxjs';
export type InterestPostingReportStatus = 'GENERATED' | 'APPROVED' | 'POSTED' | 'SUPERSEDED';
+/** Which branches a list covers: the user's own, or (head office only) every other branch. */
+export type ReportScope = 'own' | 'others';
+
export interface InterestPostingReport {
id: number;
productId: number;
@@ -32,6 +35,8 @@ export interface InterestPostingReport {
failureReason: string | null;
/** Whether the signed-in user may regenerate or approve it: their own branch, or the head office for a branch that did not open. */
canAct: boolean;
+ /** Why not, where canAct is false. */
+ blockedReason: string | null;
}
export interface InterestPostingReportLine {
@@ -60,6 +65,8 @@ export interface InterestPostingDue {
officeId: number;
officeName: string;
postingDate: string;
+ canAct: boolean;
+ blockedReason: string | null;
}
export interface InterestPostingSummaryRow {
@@ -78,6 +85,22 @@ export interface InterestPostingSummaryRow {
postedAt: string | null;
}
+export interface InterestPreviewRow {
+ officeId: number;
+ officeName: string;
+ productId: number;
+ productName: string;
+ savingsAccountId: number;
+ accountNo: string;
+ clientName: string;
+ balance: number;
+ rate: number;
+ cycleStart: string;
+ postingDate: string;
+ asOf: string;
+ interest: number;
+}
+
/**
* The savings interest posting reports, one per branch and product: generated at noon on a posting
* date, approved by the branch, posted when the branch closes the day.
@@ -87,8 +110,15 @@ export class InterestPostingReportService {
private http = inject(HttpClient);
private readonly base = '/savingsinterestpostingreports';
- list(postingDate?: string, status?: string, officeId?: number): Observable {
+ /** scope 'own' is the user's own branch; 'others' (head office only) every other branch. */
+ list(
+ postingDate?: string,
+ status?: string,
+ officeId?: number,
+ scope?: ReportScope
+ ): Observable {
let params = new HttpParams();
+ if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
if (status) params = params.set('status', status);
if (officeId) params = params.set('officeId', String(officeId));
@@ -96,12 +126,18 @@ export class InterestPostingReportService {
}
/** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
- dueWithoutReport(postingDate?: string): Observable {
+ dueWithoutReport(postingDate?: string, scope?: ReportScope): Observable {
let params = new HttpParams();
+ if (scope) params = params.set('scope', scope);
if (postingDate) params = params.set('postingDate', postingDate);
return this.http.get(`${this.base}/due-without-report`, { params });
}
+ /** The signed-in user's branch, and whether it is the head office. */
+ context(): Observable<{ officeId: number; officeName: string; headOffice: boolean }> {
+ return this.http.get<{ officeId: number; officeName: string; headOffice: boolean }>(`${this.base}/context`);
+ }
+
detail(reportId: number): Observable {
return this.http.get(`${this.base}/${reportId}`);
}
@@ -126,6 +162,13 @@ export class InterestPostingReportService {
);
}
+ /** Interest each account has earned so far this cycle, as if posted today. View only. */
+ preview(officeId?: number): Observable {
+ let params = new HttpParams();
+ if (officeId) params = params.set('officeId', String(officeId));
+ return this.http.get(`${this.base}/preview`, { params });
+ }
+
/** The head office's branch-wise view over a date range. */
summary(fromDate?: string, toDate?: string, productId?: number): Observable {
let params = new HttpParams();
diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html
index 9244384a1..f2e4c032e 100644
--- a/src/app/tds-reports/tds-interest-report.component.html
+++ b/src/app/tds-reports/tds-interest-report.component.html
@@ -13,15 +13,21 @@
Deposit Interest & TDS
- Quarter-wise and yearly interest on time deposits, with tax deductible under {{ statute.section }} ·
- {{ scopeLabel }}
+ Interest each customer earned this year, and whether TDS has to be deducted on it ·
+ Showing: {{ scopeLabel }}
+ Every customer with an account is listed. Each account is either
+ Tracked — its interest counts toward the TDS limit — or
+ Not tracked. Open a row () to see which
+ account is which, and why.
+
+
+ Only fixed and recurring deposits are tracked. Savings interest is shown for information but
+ never attracts TDS.
+
+ @if (rules) {
+
+ A customer's tracked interest for the whole year, across every branch, is checked against the limit:
+ ₹{{ rules.general | number: '1.0-0' }}, or ₹{{ rules.senior | number: '1.0-0' }} for a senior citizen (60+).
+
+
+ Once over the limit, TDS is {{ rules.rate | number: '1.0-2' }}% of the
+ whole year's interest — {{ rules.rateNoPan | number: '1.0-2' }}% if no PAN is on
+ record.
+
+ }
+
A customer who has submitted Form 15G/15H is not deducted, even over the limit.
+
+ The TDS figures here are worked out by this report from the interest actually posted. They
+ are not debited from the customer's account automatically.
+
+
+
+ }
+
{{ summary.deductees | number }}
-
Deductees
+
Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked
- @for (note of row.schedule.notes; track note) {
-
-
-
{{ note }}
-
- }
- @if (row.jointHeldInterest) {
-
-
-
- ₹{{ 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.
-
-
- }
-
+
+
+
Accounts of {{ row.deductee.name }}
-
Quarter
-
Interest
-
Cumulative
-
Liability to date
-
TDS this quarter
-
Return due
+
Account no.
+
Type
+
Tracked for TDS?
+
Interest this year
+
Postings
+
Last posted
- @for (line of row.schedule.lines; track line.quarter) {
-
+ @for (a of row.accounts; track a.accountNo) {
+
+
{{ a.accountNo }}
+
{{ accountTypeLabel[a.type] }}
- {{ line.label }}
- @if (line.thresholdCrossedHere) {
- crossed here
- }
- @if (line.declarationActive) {
- Form 121 active
- }
-
+
+ Paid without deduction because of 15G/15H:
+ ₹{{ row.paidWithoutDeduction | number: '1.0-0' }} — still reported in
+ {{ statute.statement }}
+
+
}
-
+ } @else {
+
+
+
+ This customer has no fixed or recurring deposit, so nothing here counts toward TDS. Savings
+ interest is shown for information only.
+
+
+ }
+
+ @if (row.jointHeldInterest) {
+
+
+
+ ₹{{ row.jointHeldInterest | number: '1.0-0' }} of interest is on joint deposits where this
+ customer is not the first-named holder, so it is counted under that holder instead.
+
+
+ }
}
}
@if (visibleRows.length === 0) {
-
- No customers in this section match the filters.
+
+
+ @if (loading) {
+ Loading…
+ } @else if (selectedTab === 'paying') {
+ No customer needs TDS deducted for these filters. Check the "No TDS" tab.
+ } @else {
+ No customers match these filters.
+ }
}
@@ -359,8 +433,7 @@
Showing {{ rangeStart | number }}–{{ rangeEnd | number }} of
- {{ visibleRows.length | number }}
- {{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
+ {{ visibleRows.length | number }} customers