diff --git a/package.json b/package.json
index 4e0057794..aad5a8105 100644
--- a/package.json
+++ b/package.json
@@ -15,7 +15,7 @@
"ng": "ng",
"build": "node version.js && npm run env -s && ng build --configuration production --output-hashing=none",
"build:prod": "node version.js && node --max-old-space-size=16384 ./node_modules/@angular/cli/bin/ng build --configuration production --output-hashing=none --base-href=/web-app/",
- "start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.localhost.conf.js",
+ "start": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=4096\" ng serve --proxy-config proxy.localhost.conf.js",
"start:share": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --host 0.0.0.0 --proxy-config proxy.localhost.conf.js",
"start:demo": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --proxy-config proxy.conf.js",
"serve:dev": "npm run env -s && cross-env NODE_OPTIONS=\"--max_old_space_size=16384\" ng serve --source-map --proxy-config proxy.localhost.conf.js",
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.html b/src/app/accounting/search-journal-entry/search-journal-entry.component.html
index 6938ce4bd..899514486 100644
--- a/src/app/accounting/search-journal-entry/search-journal-entry.component.html
+++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.html
@@ -279,7 +279,7 @@
-
+
@@ -288,7 +288,7 @@
-
+
@@ -353,13 +353,7 @@
-
+
@@ -368,7 +362,7 @@
-
+
@@ -539,7 +533,7 @@
-
+ #
diff --git a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
index cf8330511..a6b2aa257 100644
--- a/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
+++ b/src/app/accounting/search-journal-entry/search-journal-entry.component.ts
@@ -90,7 +90,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
// ── Misc ──────────────────────────────────────────────────────────────────
minDate = new Date(2000, 0, 1);
- maxDate = new Date();
+ maxDate: Date = null;
/** Accounting features switched off for this installation — see disabled-features.ts. */
disabledFeatures = DISABLED_ACCOUNTING_FEATURES;
@@ -208,7 +208,7 @@ export class SearchJournalEntryComponent implements OnInit, OnDestroy, AfterView
}
ngOnInit() {
- this.maxDate = this.settingsService.businessDate;
+ this.maxDate = null;
this.paginator.pageSize = 10;
this.restoreState();
this.setFilteredOffices();
diff --git a/src/app/accounts/accounts.service.ts b/src/app/accounts/accounts.service.ts
index ad7e49688..ddc9efadc 100644
--- a/src/app/accounts/accounts.service.ts
+++ b/src/app/accounts/accounts.service.ts
@@ -74,6 +74,17 @@ export class AccountsService {
return this.http.get('/fixeddepositaccounts', { params: httpParams });
}
+ /**
+ * FD Dashboard's data source: every ACTIVE FD, id/account/client/product identity, maturity date
+ * and deposit/maturity amounts only. Deliberately a separate, narrow endpoint rather than
+ * getFixedDepositAccounts() above -- that one's backend query joins in six more tables (audit
+ * who-did-what-when, currency, tax group) an unbounded dashboard fetch does not need, over columns
+ * that had no index until this feature added one (see backend migration 0571).
+ */
+ getFixedDepositMaturityDashboard(): Observable {
+ return this.http.get('/fixeddepositaccounts/maturity-dashboard');
+ }
+
getRecurringDepositAccounts(params: AccountListParams = {}): Observable {
let httpParams = new HttpParams()
.set('paged', 'true')
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 4136ae258..b95fd0ad1 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -63,6 +63,7 @@ import { CfcfrmsModule } from './cfcfrms/cfcfrms.module';
import { BankCalendarModule } from './bank-calendar/bank-calendar.module';
import { DeafModule } from './deaf/deaf.module';
import { SavingsDailyInterestModule } from './savings-daily-interest/savings-daily-interest.module';
+import { FdDashboardModule } from './fd-dashboard/fd-dashboard.module';
import { DicgcModule } from './dicgc/dicgc.module';
import { FiuModule } from './fiu/fiu.module';
import { GlReportsModule } from './gl-reports/gl-reports.module';
@@ -176,6 +177,7 @@ export function HttpLoaderFactory(http: HttpClient) {
BankCalendarModule,
DeafModule,
SavingsDailyInterestModule,
+ FdDashboardModule,
DicgcModule,
FiuModule,
GlReportsModule,
diff --git a/src/app/board-resolution/bor-report.component.html b/src/app/board-resolution/bor-report.component.html
index ec7b8bf32..1fce84acf 100644
--- a/src/app/board-resolution/bor-report.component.html
+++ b/src/app/board-resolution/bor-report.component.html
@@ -17,7 +17,12 @@
-
@if (canOperate) {
-
+ 0"
+ >
lock Close Day
}
+ @if (interestPending.length > 0) {
+
+ warning
+
+ Savings interest posting report is not approved for {{ interestPending.join(', ') }}. Approve it in Reports → Interest Posting Reports before
+ closing the day.
+
+
+ }
} @else if (loaded && canOperate) {
diff --git a/src/app/cashier/branch-day/branch-day.component.scss b/src/app/cashier/branch-day/branch-day.component.scss
index c97aebcc9..449dfced1 100644
--- a/src/app/cashier/branch-day/branch-day.component.scss
+++ b/src/app/cashier/branch-day/branch-day.component.scss
@@ -372,3 +372,30 @@
color: #d97706;
}
}
+
+// Shown under the open day while a savings interest posting report due today is unapproved.
+.bd-interest-alert {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ margin: 8px 0 12px;
+ padding: 10px 14px;
+ border: 1px solid #f59e0b;
+ border-radius: 8px;
+ background: #fef3c7;
+ color: #92400e;
+ font-size: 13px;
+
+ mat-icon {
+ flex-shrink: 0;
+ font-size: 20px;
+ width: 20px;
+ height: 20px;
+ }
+
+ a {
+ color: inherit;
+ font-weight: 600;
+ text-decoration: underline;
+ }
+}
diff --git a/src/app/cashier/branch-day/branch-day.component.ts b/src/app/cashier/branch-day/branch-day.component.ts
index da476f658..088380db1 100644
--- a/src/app/cashier/branch-day/branch-day.component.ts
+++ b/src/app/cashier/branch-day/branch-day.component.ts
@@ -189,6 +189,23 @@ export class BranchDayComponent implements OnInit {
return this.totalOf(this.closeCounts);
}
+ /**
+ * Savings products whose interest posting report is due today and not yet approved. The close is
+ * refused by the backend while any is pending; this only says so before the user tries.
+ */
+ interestPending: string[] = [];
+
+ private checkInterestReports(): void {
+ if (!this.openDay?.businessDate) {
+ this.interestPending = [];
+ return;
+ }
+ this.service
+ .interestDayCloseCheck(this.openDay.businessDate, this.openDay.officeId)
+ .pipe(catchError(() => of(null)))
+ .subscribe((res) => (this.interestPending = res?.pendingProducts ?? []));
+ }
+
load(): void {
this.loading = true;
this.errorMsg = '';
@@ -210,6 +227,7 @@ export class BranchDayComponent implements OnInit {
this.canOperate = !!res?.canOperate;
this.openDate = this.today;
this.checkFirstOpenCashMigration();
+ this.checkInterestReports();
});
this.service
diff --git a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
index 65fbf5d25..4ddc24e33 100644
--- a/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
+++ b/src/app/cashier/cash-clearing-transfer-summary/cash-clearing-transfer-summary.component.ts
@@ -11,6 +11,7 @@ import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AuthenticationService } from '../../core/authentication/authentication.service';
import { ExportSheetSpec, exportWorkbook } from '../cashier-export';
+import { businessToday } from 'app/core/utils/business-today';
interface SummaryRow {
code: string;
@@ -1316,7 +1317,7 @@ export class CashClearingTransferSummaryComponent {
private dateTag = '2607';
constructor() {
- const d = new Date();
+ const d = businessToday();
this.asOnDate = `${String(d.getDate()).padStart(2, '0')}-${String(d.getMonth() + 1).padStart(2, '0')}-${d.getFullYear()}`;
this.dateTag = `${String(d.getFullYear()).slice(-2)}${String(d.getMonth() + 1).padStart(2, '0')}`;
diff --git a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
index 383cb57d6..bede34b4b 100644
--- a/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
+++ b/src/app/cashier/cash-exchange-entry/cash-exchange-entry.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -63,7 +64,7 @@ export class CashExchangeEntryComponent implements OnInit {
}
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.exchangeDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
diff --git a/src/app/cashier/cash-settlement/cash-settlement.component.ts b/src/app/cashier/cash-settlement/cash-settlement.component.ts
index 45d5c0f93..122527d6f 100644
--- a/src/app/cashier/cash-settlement/cash-settlement.component.ts
+++ b/src/app/cashier/cash-settlement/cash-settlement.component.ts
@@ -18,6 +18,7 @@ import { MatInputModule } from '@angular/material/input';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
type PageState = 'loading' | 'no-cashier' | 'needs-reconciliation' | 'already-settled' | 'form';
@@ -90,7 +91,7 @@ export class CashSettlementComponent implements OnInit {
saving = false;
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.settlementDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
diff --git a/src/app/cashier/cash-transfer/cash-transfer.component.ts b/src/app/cashier/cash-transfer/cash-transfer.component.ts
index fa5de03f9..d818ad5e2 100644
--- a/src/app/cashier/cash-transfer/cash-transfer.component.ts
+++ b/src/app/cashier/cash-transfer/cash-transfer.component.ts
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -82,7 +83,7 @@ export class CashTransferComponent implements OnInit {
loadingVault = false;
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
diff --git a/src/app/cashier/cashier-position/cashier-position.component.ts b/src/app/cashier/cashier-position/cashier-position.component.ts
index 1ffa957b1..dc0025bdd 100644
--- a/src/app/cashier/cashier-position/cashier-position.component.ts
+++ b/src/app/cashier/cashier-position/cashier-position.component.ts
@@ -17,6 +17,7 @@ import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
import { AuthenticationService } from '../../core/authentication/authentication.service';
+import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -71,7 +72,7 @@ export class CashierPositionComponent implements OnInit {
isCashierMode = false;
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.isCashierMode = this.authService.isCashierUser();
diff --git a/src/app/cashier/cashier.service.ts b/src/app/cashier/cashier.service.ts
index 3078701f8..f3700e20c 100644
--- a/src/app/cashier/cashier.service.ts
+++ b/src/app/cashier/cashier.service.ts
@@ -205,6 +205,23 @@ export class CashierService {
return this.http.get('/branchday', { params });
}
+ /**
+ * The savings products whose interest posting report is due for the date and not yet approved —
+ * the backend refuses to close any branch's day while one is pending.
+ */
+ interestDayCloseCheck(
+ date?: string,
+ officeId?: number
+ ): Observable<{ businessDate: string; canClose: boolean; pendingProducts: string[] }> {
+ let params = new HttpParams();
+ if (date) params = params.set('date', date);
+ if (officeId) params = params.set('officeId', String(officeId));
+ return this.http.get<{ businessDate: string; canClose: boolean; pendingProducts: string[] }>(
+ '/savingsinterestpostingreports/day-close-check',
+ { params }
+ );
+ }
+
/** Days opened and never closed — today's own open day excluded. */
branchDayMissed(officeId?: number): Observable {
let params = new HttpParams();
diff --git a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
index 929a2af27..cc31a58f6 100644
--- a/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
+++ b/src/app/cashier/closing-cash-report/closing-cash-report.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { MatPaginatorModule, PageEvent } from '@angular/material/paginator';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
/** Day-wise denomination-wise closing cash collected from deposits. */
@Component({
@@ -204,7 +205,7 @@ export class ClosingCashReportComponent implements OnInit {
}
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.load();
this.service.getTellers().subscribe({ next: (t: any[]) => (this.tellers = t || []), error: () => {} });
diff --git a/src/app/cashier/day-book/day-book.component.ts b/src/app/cashier/day-book/day-book.component.ts
index 8ce3c0436..05fb8e475 100644
--- a/src/app/cashier/day-book/day-book.component.ts
+++ b/src/app/cashier/day-book/day-book.component.ts
@@ -13,6 +13,7 @@ import { MatIconModule } from '@angular/material/icon';
import { Router } from '@angular/router';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
/**
* Which part of the day's work a row came from. Set by the server, one per source register.
@@ -95,7 +96,7 @@ export class DayBookComponent implements OnInit {
/** Local date as yyyy-MM-dd — toISOString() would shift the day in any timezone behind UTC. */
private today(): string {
- const d = new Date();
+ const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
diff --git a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts
index 047384c84..e2b204491 100644
--- a/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts
+++ b/src/app/cashier/inter-cashier-transfer/inter-cashier-transfer.component.ts
@@ -17,6 +17,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface Denom {
id: number;
@@ -79,7 +80,7 @@ export class InterCashierTransferComponent implements OnInit {
}
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.transferDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service.getActiveDenominations().subscribe({
next: (list) => {
diff --git a/src/app/cashier/my-work/my-work.component.ts b/src/app/cashier/my-work/my-work.component.ts
index 4dbee818f..8c0cf4bd4 100644
--- a/src/app/cashier/my-work/my-work.component.ts
+++ b/src/app/cashier/my-work/my-work.component.ts
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { catchError, finalize, of } from 'rxjs';
import { CashierService } from '../cashier.service';
import { VoucherDetailDialogComponent } from './voucher-detail-dialog.component';
+import { businessToday } from 'app/core/utils/business-today';
/** What the signed-in user's part in a voucher was. */
export type MyRole = 'RAISED' | 'VERIFIED' | 'RAISED_AND_VERIFIED';
@@ -357,7 +358,7 @@ export class MyWorkComponent implements OnInit {
}
private todayIso(): string {
- const d = new Date();
+ const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
}
diff --git a/src/app/cashier/reconciliation/reconciliation.component.ts b/src/app/cashier/reconciliation/reconciliation.component.ts
index 40e4eeae2..a41c15412 100644
--- a/src/app/cashier/reconciliation/reconciliation.component.ts
+++ b/src/app/cashier/reconciliation/reconciliation.component.ts
@@ -18,6 +18,7 @@ import { MatTabsModule } from '@angular/material/tabs';
import { forkJoin, of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -85,7 +86,7 @@ export class ReconciliationComponent implements OnInit {
loadingHistory = false;
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.reconDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
.getTellers()
diff --git a/src/app/cashier/transaction-entry/transaction-entry.component.ts b/src/app/cashier/transaction-entry/transaction-entry.component.ts
index aa4c8ca6f..aca25b5d5 100644
--- a/src/app/cashier/transaction-entry/transaction-entry.component.ts
+++ b/src/app/cashier/transaction-entry/transaction-entry.component.ts
@@ -33,6 +33,7 @@ import {
import { SponsorDDService } from '../../sponsor-dd/sponsor-dd.service';
import { OrganizationService } from '../../organization/organization.service';
import { CashierIssueDdDialogComponent } from './cashier-issue-dd-dialog.component';
+import { businessToday, businessTodayIso } from 'app/core/utils/business-today';
interface TxnRow {
typ: string;
@@ -621,7 +622,7 @@ export class AccountStatementDialogComponent implements OnInit {
@ViewChild('statementContent') statementContent!: ElementRef;
- today = new Date().toISOString().slice(0, 10);
+ today = businessTodayIso();
fromDate = '';
toDate = this.today;
@@ -1870,7 +1871,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
saving = false;
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.today = `${String(d.getDate()).padStart(2, '0')}/${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`;
this.addRow();
this.loadDenominations();
@@ -2103,7 +2104,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
private loadOpeningPosition(): void {
- const d = new Date();
+ const d = businessToday();
const asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.cashierService
.getCashierPositionForMe(asOfDate)
@@ -2837,7 +2838,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
fundingAccountId: number,
fundingClientId: number | null
): void {
- const d = new Date();
+ const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
const isGl = row.acTyp === 'GL';
@@ -2969,7 +2970,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const d = new Date();
+ const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const isCurrent = row.acTyp === 'CA' || row.acTyp === 'CB';
@@ -3068,7 +3069,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const d = new Date();
+ const d = businessToday();
const issuedDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const payload = {
@@ -3391,7 +3392,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const today = new Date();
+ const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -3512,7 +3513,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const today = new Date();
+ const today = businessToday();
const requestedDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Full snapshot of the CR (settlement) row exactly as the clerk filled it in — saved verbatim
@@ -4173,7 +4174,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
): void {
const accountUrl = `${cfg.detail}/${accountId}${cfg.detailParams ?? '?associations=all'}`;
const todayStr = (() => {
- const d = new Date();
+ const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
})();
@@ -4549,7 +4550,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
}
get todayIso(): string {
- const d = new Date();
+ const d = businessToday();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
@@ -5071,7 +5072,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.sameDayChequeClearing = 0;
return;
}
- const today = new Date();
+ const today = businessToday();
const sameDay = (d: any) => {
if (!d) return false;
const t = Array.isArray(d) && d.length >= 3 ? new Date(d[0], d[1] - 1, d[2]) : new Date(d);
@@ -5959,7 +5960,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const today = new Date();
+ const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const payload: any = {
@@ -6041,7 +6042,12 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
this.editRequest = req;
}
- const acTyp = 'SD';
+ // Same bug, same fix as the token-driven withdrawal-slip lookup above (see its comment): the
+ // request carries its own real account_type (SAVINGS, CURRENT, FIXED_DEPOSIT or
+ // RECURRING_DEPOSIT), and hardcoding 'SD' here searched every non-savings account on the
+ // wrong endpoint, so reviewing a Current-account withdrawal always failed with "Account not
+ // found".
+ const acTyp = TransactionEntryComponent.SEARCH_ACCOUNT_TYPE_TO_AC_TYP[req.account_type || ''] || 'SD';
const row = this.rows[0];
row.acNo = req.account_no ?? '';
row.acTyp = acTyp;
@@ -6149,7 +6155,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the withdrawal-request payload from the current row + loaded account. */
private buildWithdrawalPayload(row: TxnRow, amount: number): any {
- const today = new Date();
+ const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
@@ -6254,7 +6260,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
/** Build the deposit-request payload from the current row + loaded account. */
private buildDepositPayload(row: TxnRow, amount: number): any {
- const today = new Date();
+ const today = businessToday();
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const selectedPt = this.paymentTypes.find((p: any) => p.id === row.paymentTypeId);
// Send denominations only for cash payments — stale counts on a cheque
@@ -6365,7 +6371,7 @@ export class TransactionEntryComponent implements OnInit, OnDestroy {
return;
}
- const today = new Date();
+ const today = businessToday();
const txnDate = `${String(today.getDate()).padStart(2, '0')}/${String(today.getMonth() + 1).padStart(2, '0')}/${today.getFullYear()}`;
const txnDateIso = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
diff --git a/src/app/cashier/vault-position/vault-position.component.ts b/src/app/cashier/vault-position/vault-position.component.ts
index d91cbdb2d..064d8b58d 100644
--- a/src/app/cashier/vault-position/vault-position.component.ts
+++ b/src/app/cashier/vault-position/vault-position.component.ts
@@ -16,6 +16,7 @@ import { MatInputModule } from '@angular/material/input';
import { of } from 'rxjs';
import { catchError } from 'rxjs/operators';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface DenomRow {
denominationId: number;
@@ -67,7 +68,7 @@ export class VaultPositionComponent implements OnInit {
errorMsg = '';
ngOnInit(): void {
- const d = new Date();
+ const d = businessToday();
this.asOfDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
this.service
diff --git a/src/app/cashier/voucher-report/voucher-report.component.ts b/src/app/cashier/voucher-report/voucher-report.component.ts
index 8444b2813..d6c148d91 100644
--- a/src/app/cashier/voucher-report/voucher-report.component.ts
+++ b/src/app/cashier/voucher-report/voucher-report.component.ts
@@ -13,6 +13,7 @@ import { ActivatedRoute } from '@angular/router';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
/**
* Which side of the register a screen shows.
@@ -227,7 +228,7 @@ export class VoucherReportComponent implements OnInit {
}
private todayIso(): string {
- const now = new Date();
+ const now = businessToday();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
}
diff --git a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
index ed53470c4..db39bae63 100644
--- a/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
+++ b/src/app/clients/client-stepper/client-general-step/client-general-step.component.ts
@@ -643,8 +643,9 @@ export class ClientGeneralStepComponent implements OnInit, OnDestroy, OnChanges
this.createClientForm.get('degreeQualificationId')?.setValue('');
}
});
- // Fetch all constitution options from code ID 17
- this.clientService.getCodeValuesById(17).subscribe((options) => {
+ // Constitution options by code NAME, never by id: code ids differ per tenant (on abc_bank id 17 is the empty
+ // ClientClassification code and Constitution is id 24), so a hardcoded id left the dropdown empty.
+ this.clientService.getCodeValuesByName('Constitution').subscribe((options) => {
if (options?.length) {
this.constitutionOptions = options;
// Sort master by value length descending so longer/more-specific keys match first
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
index f139711f3..65d2d97da 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.html
@@ -1321,6 +1321,11 @@
kycForm.get('kycDocumentNumber')?.hasError('pattern') && kycForm.get('kycDocumentNumber')?.touched
) {
Only letters, numbers, hyphens and slashes allowed
+ } @else if (
+ kycForm.get('kycDocumentNumber')?.hasError('aadhaarLast4Mismatch') &&
+ kycForm.get('kycDocumentNumber')?.touched
+ ) {
+ Last 4 digits do not match the Aadhaar number entered above
}
diff --git a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
index 00d2afccd..cfca4fb8c 100644
--- a/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
+++ b/src/app/clients/client-stepper/client-kyc-step/client-kyc-step.component.ts
@@ -8,7 +8,14 @@
import { Component, Input, OnInit, OnChanges, OnDestroy, SimpleChanges, inject } from '@angular/core';
import { forkJoin, catchError, of } from 'rxjs';
-import { UntypedFormBuilder, UntypedFormGroup, Validators, FormsModule } from '@angular/forms';
+import {
+ UntypedFormBuilder,
+ UntypedFormGroup,
+ Validators,
+ FormsModule,
+ AbstractControl,
+ ValidationErrors
+} from '@angular/forms';
import {
MatDialog,
MAT_DIALOG_DATA,
@@ -567,6 +574,28 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
/** Existing signature document ID (for re-upload/delete) */
existingSignatureDocId: string = '';
+ /**
+ * Cross-field check for the OVD "Document Number" field (kycDocumentNumber): when Aadhaar Card
+ * is the selected KYC document, its value must identify the same Aadhaar as the aadhaarNumber
+ * field captured earlier in this step - the last 4 digits are what's compared, since
+ * aadhaarNumber may hold either the raw 12-digit number or a masked value (masking only ever
+ * obscures the first 8 digits - see aadhaarMasked in the kycData getter below - so the last 4
+ * characters are always the real digits either way). Not applicable to any other document type,
+ * where kycDocumentNumber is unrelated to Aadhaar entirely.
+ */
+ private aadhaarLast4MatchValidator = (control: AbstractControl): ValidationErrors | null => {
+ if (this.selectedKycDoc?.code !== 'AADHAAR') {
+ return null;
+ }
+ const docNumber = (control.value || '').toString().trim();
+ const aadhaarValue = (control.parent?.get('aadhaarNumber')?.value || '').toString().trim();
+ if (docNumber.length < 4 || aadhaarValue.length < 4) {
+ // Required/pattern validators already flag an empty or too-short value on their own.
+ return null;
+ }
+ return docNumber.slice(-4) === aadhaarValue.slice(-4) ? null : { aadhaarLast4Mismatch: true };
+ };
+
/** KYC Form */
kycForm: UntypedFormGroup = this.formBuilder.group({
hasPan: [true],
@@ -616,7 +645,8 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
[
Validators.required,
Validators.pattern(/^[A-Za-z0-9\-\/\s]+$/),
- Validators.maxLength(30)
+ Validators.maxLength(30),
+ this.aadhaarLast4MatchValidator
]
],
kycDocIssueDate: [''],
@@ -739,6 +769,14 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
this.kycForm.get('ecgcSalFlag')?.disable();
this.kycForm.get('dormantFlag')?.disable();
}
+
+ // aadhaarLast4MatchValidator (on kycDocumentNumber) reads this control's value directly rather
+ // than through its own valueChanges, so a re-check has to be triggered explicitly whenever the
+ // Aadhaar field itself changes - otherwise fixing aadhaarNumber after kycDocumentNumber was
+ // already entered would leave a stale mismatch error in place.
+ this.kycForm.get('aadhaarNumber')?.valueChanges.subscribe(() => {
+ this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
+ });
}
/** Seed the document-type picker from defaults (entity RBI docs vs. individual OVDs,
@@ -1009,6 +1047,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Reset uploads + merged preview when document type changes
this.kycDocFiles = { front: null, back: null };
this.kycDocMergedPreview = null;
+ // aadhaarLast4MatchValidator only applies while Aadhaar Card is selected - re-check so switching
+ // to/away from it immediately picks up (or clears) the mismatch error.
+ this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
isKycDocSelected(proof: ProofType): boolean {
@@ -1189,6 +1230,9 @@ export class ClientKycStepComponent implements OnInit, OnChanges, OnDestroy {
// Sync chip-picker selection with the prefilled document name
if (data.kycDocumentName) {
this.selectedKycDoc = this.findKycDocProofByName(data.kycDocumentName);
+ // patchValue above ran kycDocumentNumber's validators before selectedKycDoc was known,
+ // so aadhaarLast4MatchValidator needs a fresh check now that it is.
+ this.kycForm.get('kycDocumentNumber')?.updateValueAndValidity({ onlySelf: true });
}
// Set verification statuses from existing data
if (data.panVerified) {
diff --git a/src/app/clients/clients-routing.module.ts b/src/app/clients/clients-routing.module.ts
index 22ee81703..cb7b21e06 100644
--- a/src/app/clients/clients-routing.module.ts
+++ b/src/app/clients/clients-routing.module.ts
@@ -61,6 +61,7 @@ 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 { tdsConfigResolver } from '../letter-mgmt/certificates/form-15gh/tds-config.service';
import { EntityClassificationTabComponent } from './clients-view/entity-classification-tab/entity-classification-tab.component';
import { AuthorizedSignatoriesTabComponent } from './clients-view/authorized-signatories-tab/authorized-signatories-tab.component';
import { familyAllowedGuard, nomineeAllowedGuard } from './utils/family-nominee.guard';
@@ -198,6 +199,7 @@ const routes: Routes = [
{
path: 'form121',
component: Form121TabComponent,
+ resolve: { tdsConfig: tdsConfigResolver },
data: { title: 'Form 121 / TDS', breadcrumb: 'Form 121 / TDS', routeParamBreadcrumb: false }
},
{
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
index 4f71a5b6d..4beac80ce 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.html
@@ -202,9 +202,14 @@
{{ existing.CLIENT_SIGNATURE.name }}
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
index 602d2dd3e..94b6f1336 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.scss
@@ -623,6 +623,35 @@
}
}
+/* Opens the uploaded artefact full size in a new tab. Sits directly under the thumbnail, and is
+ the only way to inspect a PDF - which cannot render as an inline . */
+.ckyc-existing__view {
+ align-self: flex-start;
+ color: #1d6fd0;
+ border-color: #bcd8f5;
+ background: #fff;
+ font-size: 13px;
+ font-weight: 600;
+
+ &:hover {
+ background: #eef6ff;
+ }
+}
+
+/* Shown when a document row exists but its file could not be fetched from the content store,
+ so "Already available" is not silently misleading. */
+.ckyc-existing__warn {
+ display: inline-block;
+ margin-left: 8px;
+ padding: 1px 8px;
+ border-radius: 10px;
+ background: #fdecec;
+ border: 1px solid #f3c2c2;
+ color: #a12b2b;
+ font-size: 11px;
+ font-weight: 600;
+}
+
.ckyc-existing {
display: flex;
flex-direction: column;
diff --git a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
index 0acbbf2d8..b90ec1d8c 100644
--- a/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
+++ b/src/app/clients/clients-view/ckyc-tab/ckyc-tab.component.ts
@@ -36,11 +36,41 @@ interface ExistingArtefact {
* already writes/reads for address proof; now shared by the photo/identity proof too. */
description?: string;
fileCount?: number;
+ /** Set when the stored file could not be fetched - the row exists but its content is gone. */
+ previewUnavailable?: boolean;
+ /** Stored MIME type, so the card knows whether it can render an inline thumbnail. */
+ mimeType?: string;
}
type ProofKind = 'ADDRESS_PROOF' | 'PHOTO_PROOF';
type CkycDocKind = ProofKind | 'CLIENT_IMAGE' | 'CLIENT_SIGNATURE';
+/**
+ * Client-side upload guards, kept deliberately in step with the backend so a file that passes here
+ * is not rejected server-side:
+ * - 5 MB matches `spring.servlet.multipart.max-file-size`
+ * - the extension list is the intersection of the backend `fineract.content.regex-whitelist`
+ * with what these cards can actually render.
+ * The dialog's `accept=` is only a file-picker hint and is trivially bypassed, so the real check
+ * runs here, on the file the dialog hands back, before anything is sent.
+ */
+const MAX_UPLOAD_BYTES = 5 * 1024 * 1024;
+const IMAGE_EXTENSIONS = [
+ 'png',
+ 'jpeg',
+ 'jpg'
+];
+const DOCUMENT_EXTENSIONS = [
+ ...IMAGE_EXTENSIONS,
+ 'pdf'
+];
+
+/** Cards that render an inline , so a PDF would not display in them. */
+const IMAGE_ONLY_KINDS: CkycDocKind[] = [
+ 'CLIENT_IMAGE',
+ 'CLIENT_SIGNATURE'
+];
+
@Component({
selector: 'mifosx-ckyc-tab',
templateUrl: './ckyc-tab.component.html',
@@ -150,6 +180,11 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnInit(): void {
this.route.parent.params.subscribe((params: any) => {
this.clientId = params['clientId'];
+ // Angular reuses this component when only the clientId in the route changes, and
+ // loadExistingArtefacts() only ever *assigns* what it finds - it has no "not found" branch.
+ // Without clearing first, a client with no photo (or no signature, or no proof) keeps showing
+ // the previous client's card.
+ this.resetArtefactState();
this.loadClientInfo();
this.loadAccounts();
this.loadCkycData();
@@ -157,6 +192,46 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
+ /**
+ * Returns every per-client card to its empty state, so nothing from the client just viewed can
+ * survive into the next one. Covers all four cards plus the details staged for an upload - a file
+ * or document number left over from another client must never be submitted against this one.
+ */
+ private resetArtefactState(): void {
+ this.revokePreviewUrls();
+ this.existing = {
+ ADDRESS_PROOF: { present: false },
+ PHOTO_PROOF: { present: false },
+ CLIENT_IMAGE: { present: false },
+ CLIENT_SIGNATURE: { present: false }
+ };
+ this.selectedProofType = { ADDRESS_PROOF: null, PHOTO_PROOF: null };
+ this.docFiles = {
+ ADDRESS_PROOF: { front: null, back: null },
+ PHOTO_PROOF: { front: null, back: null }
+ };
+ this.documentDetails = {
+ ADDRESS_PROOF: { documentKey: '', issueDate: null, expiryDate: null },
+ PHOTO_PROOF: { documentKey: '', issueDate: null, expiryDate: null }
+ };
+ this.clientImageFile = null;
+ this.clientSignatureFile = null;
+ this.uploadErrors = {
+ ADDRESS_PROOF: null,
+ PHOTO_PROOF: null,
+ CLIENT_IMAGE: null,
+ CLIENT_SIGNATURE: null
+ };
+ }
+
+ /** Frees the blob URLs held by the current cards before their references are dropped. */
+ private revokePreviewUrls(): void {
+ Object.values(this.existing).forEach((e) => {
+ if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
+ if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
+ });
+ }
+
private loadClientInfo(): void {
this.route.parent.data.subscribe((data: any) => {
const cv = data?.clientViewData;
@@ -280,19 +355,29 @@ export class CkycTabComponent implements OnInit, OnDestroy {
present: true,
source: 'Client registration',
name: signatureDoc.fileName || 'Client signature',
- documentId: signatureDoc.id
+ documentId: signatureDoc.id,
+ mimeType: signatureDoc.type
};
- // Fetch blob for preview
+ // Same endpoint getClientSignatureImage() uses, but opted out of the global error alert:
+ // a m_document row whose file is no longer in the content store returns 500, and the card
+ // below reports that itself. Without `silent` the interceptor pops an "Internal Server
+ // Error" modal on every page load, for a failure the user cannot act on.
this.clientsService
- .getClientSignatureImage(this.clientId, signatureDoc.id)
+ .downloadClientDocument(this.clientId, String(signatureDoc.id), true)
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
- if (blob) {
+ if (blob && blob.size > 0) {
this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
+ } else {
+ // A 200 with an empty body still means there is nothing to show. Without this the
+ // card would claim "Already available" with no thumbnail and no explanation.
+ this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
}
},
- error: () => undefined
+ error: () => {
+ this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
+ }
});
}
@@ -364,6 +449,7 @@ export class CkycTabComponent implements OnInit, OnDestroy {
const list = Array.isArray(docs) ? docs : [];
if (!list.length) return;
this.existing[kind].fileCount = list.length;
+ this.existing[kind].previewUnavailable = false;
const byNewest = (a: any, b: any) => b.id - a.id;
const label = (d: any) => (d?.name || d?.fileName || '').toString();
@@ -372,6 +458,9 @@ export class CkycTabComponent implements OnInit, OnDestroy {
[...list].filter((d: any) => /front/i.test(label(d))).sort(byNewest)[0] ||
[...list].filter((d: any) => d.id !== back?.id).sort(byNewest)[0];
+ // A proof can be scanned to PDF, which no can render - remember what was stored so the
+ // card offers "View document" instead of a broken thumbnail.
+ if (front) this.existing[kind].mimeType = front.type;
if (front) this.downloadIdentifierDocPreview(identifierId, front.id, kind, 'front');
if (back && back.id !== front?.id) this.downloadIdentifierDocPreview(identifierId, back.id, kind, 'back');
});
@@ -388,12 +477,19 @@ export class CkycTabComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.destroy$))
.subscribe({
next: (blob: any) => {
- if (!blob) return;
+ if (!blob || blob.size === 0) {
+ this.existing[kind].previewUnavailable = true;
+ return;
+ }
const url = URL.createObjectURL(blob);
if (slot === 'front') this.existing[kind].previewUrl = url;
else this.existing[kind].previewUrlBack = url;
},
- error: () => undefined
+ // The row exists but its file is gone from the content store. Saying so on the card beats
+ // showing "Already available" next to a blank space.
+ error: () => {
+ this.existing[kind].previewUnavailable = true;
+ }
});
}
@@ -452,6 +548,18 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ref.afterClosed().subscribe((result: CkycUploadDialogResult | null) => {
if (!result) return;
+
+ // The dialog's `accept=` filter is only a hint to the file picker and is trivially bypassed,
+ // so the size/type guard runs here on what actually came back. Catching it now turns a raw
+ // server rejection into a message on the card.
+ const rejection =
+ this.validationError(result.front, kind) ?? (result.back ? this.validationError(result.back, kind) : null);
+ if (rejection) {
+ this.uploadErrors[kind] = rejection;
+ return;
+ }
+ this.uploadErrors[kind] = null;
+
if (isProofKind) {
const proofKind = kind as ProofKind;
this.selectedProofType[proofKind] = result.proofType || null;
@@ -470,6 +578,66 @@ export class CkycTabComponent implements OnInit, OnDestroy {
});
}
+ /** Extensions a given card can both store and render. */
+ private allowedExtensions(kind: CkycDocKind): string[] {
+ return IMAGE_ONLY_KINDS.includes(kind) ? IMAGE_EXTENSIONS : DOCUMENT_EXTENSIONS;
+ }
+
+ /**
+ * Rejects a file the backend would refuse (or the card could not render) before it is uploaded,
+ * so the user gets a clear message instead of a raw server error. Returns null when the file is
+ * acceptable.
+ */
+ private validationError(file: File | null | undefined, kind: CkycDocKind): string | null {
+ if (!file) {
+ return null;
+ }
+ const allowed = this.allowedExtensions(kind);
+ const extension = (file.name.split('.').pop() || '').toLowerCase();
+
+ if (!allowed.includes(extension)) {
+ return `Unsupported file type ".${extension}". Allowed: ${allowed.map((e) => e.toUpperCase()).join(', ')}.`;
+ }
+ if (file.size > MAX_UPLOAD_BYTES) {
+ const mb = (file.size / (1024 * 1024)).toFixed(1);
+ return `File is ${mb} MB. The maximum allowed size is 5 MB.`;
+ }
+ if (file.size === 0) {
+ return 'The selected file is empty.';
+ }
+ return null;
+ }
+
+ /**
+ * True when the stored file can be shown as an inline thumbnail. A PDF cannot, so its card falls
+ * back to the "View document" button alone rather than rendering a broken image.
+ */
+ isImage(kind: CkycDocKind): boolean {
+ const entry = this.existing[kind];
+ // The profile image arrives as a `data:image/...;base64,` URL, which declares its own type -
+ // it has neither a stored MIME type nor a file extension for the checks below to read.
+ if (entry?.previewUrl?.startsWith('data:')) {
+ return entry.previewUrl.startsWith('data:image/');
+ }
+ if (entry?.mimeType) {
+ return entry.mimeType.startsWith('image/');
+ }
+ // Older rows can have no stored MIME type - fall back to the file extension.
+ const extension = (entry?.name?.split('.').pop() || '').toLowerCase();
+ return IMAGE_EXTENSIONS.includes(extension);
+ }
+
+ /**
+ * Opens the uploaded document full size in a new tab. Uses the blob already fetched for the
+ * thumbnail, so it works for PDFs too and costs no extra request.
+ */
+ openDocument(kind: CkycDocKind): void {
+ const url = this.existing[kind]?.previewUrl;
+ if (url) {
+ window.open(url, '_blank', 'noopener');
+ }
+ }
+
/** Reads back the "Issue date: yyyy-MM-dd, Expiry date: yyyy-MM-dd" convention this tab (and
* client-india-address-step, for address proof) writes into the identifier's description. */
private parseDescriptionDates(description: string | undefined): { issueDate: Date | null; expiryDate: Date | null } {
@@ -533,6 +701,10 @@ export class CkycTabComponent implements OnInit, OnDestroy {
request$.pipe(takeUntil(this.destroy$)).subscribe({
next: () => {
this.uploading[documentType] = false;
+ // The card is gated on `present`; without this a successful upload left it reading
+ // "Not uploaded yet". uploadProofDocument() sets the same flag for the proof cards.
+ this.existing[key].present = true;
+ this.existing[key].source = this.existing[key].source || 'CKYC upload';
this.alertService.alert({ type: 'success', message: 'Document uploaded successfully.' });
this.refreshPreview(documentType);
},
@@ -653,13 +825,22 @@ export class CkycTabComponent implements OnInit, OnDestroy {
if (this.existing[staleKey].previewUrlBack?.startsWith('blob:')) {
URL.revokeObjectURL(this.existing[staleKey].previewUrlBack!);
}
+ // The upload that triggered this refresh is what repairs a previously missing file, so the
+ // warning has to be cleared before the re-read - otherwise a fixed card keeps the old badge.
+ this.existing[staleKey].previewUrl = undefined;
+ this.existing[staleKey].previewUrlBack = undefined;
+ this.existing[staleKey].previewUnavailable = false;
switch (documentType) {
case 'CLIENT_IMAGE':
this.clientsService
.getClientProfileImage(this.clientId)
.pipe(takeUntil(this.destroy$))
.subscribe((photo: any) => {
- if (photo) this.existing.CLIENT_IMAGE.previewUrl = photo;
+ if (photo) {
+ this.existing.CLIENT_IMAGE.previewUrl = photo;
+ } else {
+ this.existing.CLIENT_IMAGE.previewUnavailable = true;
+ }
});
break;
case 'CLIENT_SIGNATURE':
@@ -672,11 +853,21 @@ export class CkycTabComponent implements OnInit, OnDestroy {
?.sort((a: any, b: any) => b.id - a.id)?.[0];
if (!doc) return;
this.existing.CLIENT_SIGNATURE.documentId = doc.id;
+ this.existing.CLIENT_SIGNATURE.mimeType = doc.type;
this.clientsService
- .getClientSignatureImage(this.clientId, doc.id)
+ .downloadClientDocument(this.clientId, String(doc.id), true)
.pipe(takeUntil(this.destroy$))
- .subscribe((blob: any) => {
- if (blob) this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
+ .subscribe({
+ next: (blob: any) => {
+ if (blob && blob.size > 0) {
+ this.existing.CLIENT_SIGNATURE.previewUrl = URL.createObjectURL(blob);
+ } else {
+ this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
+ }
+ },
+ error: () => {
+ this.existing.CLIENT_SIGNATURE.previewUnavailable = true;
+ }
});
});
break;
@@ -690,10 +881,6 @@ export class CkycTabComponent implements OnInit, OnDestroy {
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
- // Release object URLs
- Object.values(this.existing).forEach((e) => {
- if (e.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrl);
- if (e.previewUrlBack?.startsWith('blob:')) URL.revokeObjectURL(e.previewUrlBack);
- });
+ this.revokePreviewUrls();
}
}
diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html
index 1b75a798d..1a351078d 100644
--- a/src/app/clients/clients-view/form121-tab/form121-tab.component.html
+++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.html
@@ -55,7 +55,10 @@
info_outline
- @if (!dateOfBirth) {
+ @if (configMissing) {
+ TDS rates for FY {{ fy }} are not configured, so the tax cannot be computed. Set them up under Organization
+ › TDS Configuration.
+ } @else 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 {
@@ -113,8 +116,8 @@
warning_amber
- 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.
+ A declaration is on record for this year, but one or more deposits still carry a tax group. Those accounts
+ deduct on their own and ignore the declaration — remove the tax group from them.
+ Accepted on
- Not captured — the declaration is treated as covering the whole year. A mid-year acceptance would change
- the figures below.
+ {{ d.acceptedOn | date: 'd MMM y' }} — no TDS on interest credited from this day to 31 March. Interest
+ credited before it is taxed as usual.
- No scan is on file for this document. Upload one below.
-
+
No scan is on file for this document. Upload one below.
}
diff --git a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
index 84743bb5b..611abcf88 100644
--- a/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
+++ b/src/app/clients/clients-view/kyc-tab/kyc-tab.component.ts
@@ -182,7 +182,10 @@ export class KycTabComponent implements OnInit, OnDestroy {
docScans: { front: DocScanSide | null; back: DocScanSide | null } = { front: null, back: null };
/** The two sides, in display order, so the template loops rather than repeating itself. */
- readonly docScanSlots: ('front' | 'back')[] = ['front', 'back'];
+ readonly docScanSlots: ('front' | 'back')[] = [
+ 'front',
+ 'back'
+ ];
docScanOf(slot: 'front' | 'back') {
return this.docScans[slot];
@@ -474,7 +477,8 @@ export class KycTabComponent implements OnInit, OnDestroy {
adverseMediaMatch: !!v.adverseMediaMatch,
// Blank stays null rather than becoming zero: an income nobody asked for is unknown, and a zero would read as a
// customer who declared no income at all.
- declaredAnnualIncome: v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
+ declaredAnnualIncome:
+ v.declaredAnnualIncome === '' || v.declaredAnnualIncome == null ? null : v.declaredAnnualIncome,
declaredAnnualTurnover:
v.declaredAnnualTurnover === '' || v.declaredAnnualTurnover == null ? null : v.declaredAnnualTurnover
};
@@ -519,7 +523,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
dateFormat: 'dd MMMM yyyy',
...this.buildDocumentFields(),
...this.buildRiskProfileFields(),
- ...this.buildExceptionFields()
+ ...this.buildExceptionFields()
};
if (!this.kycId) {
@@ -766,7 +770,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...declarationFields,
...this.buildRiskProfileFields(),
- ...this.buildExceptionFields()
+ ...this.buildExceptionFields()
};
this.clientsService
@@ -927,7 +931,7 @@ export class KycTabComponent implements OnInit, OnDestroy {
riskCategory: this.kycForm.value.riskCategory || 1,
...receiptFields,
...this.buildRiskProfileFields(),
- ...this.buildExceptionFields()
+ ...this.buildExceptionFields()
};
this.clientsService
@@ -1455,7 +1459,10 @@ I further confirm that:
this.docScansLoading = false;
// This runs again after every replace, so the previews being thrown away are revoked
// rather than left holding their blobs.
- for (const old of [this.docScans.front, this.docScans.back]) {
+ for (const old of [
+ this.docScans.front,
+ this.docScans.back
+ ]) {
if (old?.previewUrl) {
URL.revokeObjectURL(old.previewUrl);
}
@@ -1497,7 +1504,10 @@ I further confirm that:
}
this.docScans = sides;
- for (const key of ['front', 'back'] as const) {
+ for (const key of [
+ 'front',
+ 'back'
+ ] as const) {
if (sides[key]) {
this.loadScanPreview(sides[key]!);
}
@@ -1558,7 +1568,9 @@ I further confirm that:
error: (err: any) => {
this.docScanUploading = null;
this.docScanError =
- err?.error?.defaultUserMessage || err?.error?.developerMessage || `Could not replace the ${label.toLowerCase()} side.`;
+ err?.error?.defaultUserMessage ||
+ err?.error?.developerMessage ||
+ `Could not replace the ${label.toLowerCase()} side.`;
}
});
}
@@ -1741,7 +1753,10 @@ I further confirm that:
this.destroy$.next();
this.destroy$.complete();
// Object URLs are held by the browser until revoked, so the scan blobs would outlive the tab.
- for (const side of [this.docScans.front, this.docScans.back]) {
+ for (const side of [
+ this.docScans.front,
+ this.docScans.back
+ ]) {
if (side?.previewUrl) {
URL.revokeObjectURL(side.previewUrl);
}
diff --git a/src/app/clients/clients.service.ts b/src/app/clients/clients.service.ts
index 7220fbe47..cd08181ce 100644
--- a/src/app/clients/clients.service.ts
+++ b/src/app/clients/clients.service.ts
@@ -569,8 +569,19 @@ export class ClientsService {
return this.http.get(`/clients/${clientId}/documents`);
}
- downloadClientDocument(parentEntityId: string, documentId: string) {
- return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, { responseType: 'blob' });
+ /**
+ * Fetches a client document's stored file.
+ *
+ * `silent` opts the request out of ErrorHandlerInterceptor's alert. Pass it when the caller
+ * renders its own failure state - notably thumbnail fetches, where a `m_document` row whose file
+ * is no longer in the content store returns 500 and would otherwise pop a modal the user can do
+ * nothing about. The interceptor still rethrows, so the caller's error callback continues to run.
+ */
+ downloadClientDocument(parentEntityId: string, documentId: string, silent = false) {
+ return this.http.get(`/clients/${parentEntityId}/documents/${documentId}/attachment`, {
+ responseType: 'blob',
+ ...(silent ? { context: new HttpContext().set(SILENT_HTTP_ERROR, true) } : {})
+ });
}
uploadClientDocument(clientId: string, documentData: any) {
@@ -992,12 +1003,28 @@ export class ClientsService {
return this.http.post(`/clients/${clientId}/entityclassification/${category}`, data);
}
- getClientCkyc(clientId: string): Observable {
- return this.http.get(`/clients/${clientId}/ckyc`);
+ /**
+ * Uploads a single CKYC artefact against the client, using the platform's generic document
+ * endpoint (DocumentApiResource, POST /v1/{entityType}/{entityId}/documents). There is no
+ * CKYC-specific document API on the backend - the earlier `/clients/{id}/ckyc/documents` path
+ * was never implemented and returned 404.
+ *
+ * The multipart body must carry `file`, `name` and `description`; the caller builds those so the
+ * stored document stays identifiable by CKYC artefact type. See ckyc-tab.component.ts.
+ */
+ uploadCkycDocument(clientId: string, formData: FormData): Observable {
+ return this.http.post(`/clients/${clientId}/documents`, formData);
}
- uploadCkycDocument(clientId: string, formData: FormData): Observable {
- return this.http.post(`/clients/${clientId}/ckyc/documents`, formData);
+ /**
+ * Replaces the content of an existing client document in place (DocumentApiResource PUT
+ * /v1/{entityType}/{entityId}/documents/{documentId}), keeping the same document id.
+ *
+ * Used by the CKYC screen's "Upload new version" so a re-upload updates the existing artefact
+ * rather than adding another row. Takes the same multipart body as the create call.
+ */
+ replaceClientDocument(clientId: string, documentId: string, formData: FormData): Observable {
+ return this.http.put(`/clients/${clientId}/documents/${documentId}`, formData);
}
/**
diff --git a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
index 6e646f72a..bf720407f 100644
--- a/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
+++ b/src/app/clients/shared/kyc-document-update-dialog/kyc-document-update-dialog.component.html
@@ -109,13 +109,7 @@
}
-
+
}
diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts
index b20947383..d20aca013 100644
--- a/src/app/core/http/cache.interceptor.ts
+++ b/src/app/core/http/cache.interceptor.ts
@@ -25,6 +25,36 @@ import { environment } from '../../../environments/environment';
export class CacheInterceptor implements HttpInterceptor {
private forceUpdate = false;
+ /**
+ * Endpoints that compute a live figure from the current ledger/configuration and must never be
+ * served from cache. Caching them meant a report kept showing a stale response after its
+ * underlying GL mapping changed — the request was answered from cachedData and the backend was
+ * never hit again. Matched as a substring of the request URL.
+ */
+ private static readonly NEVER_CACHE = [
+ '/rbireservereturn',
+ '/rbireserveconfig',
+ '/tdsconfig',
+ '/tds/deposit-interest',
+ // Declarations and challans are entered on the very screens that read them back.
+ '/tds/declarations',
+ '/tds/challans',
+ '/tds/deducted',
+ '/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 {
+ return !CacheInterceptor.NEVER_CACHE.some((fragment) => url.includes(fragment));
+ }
+
// eslint-disable-next-line @angular-eslint/prefer-inject
constructor(private httpCacheService: HttpCacheService) {}
@@ -42,7 +72,7 @@ export class CacheInterceptor implements HttpInterceptor {
}
intercept(request: HttpRequest, next: HttpHandler): Observable> {
- if (request.method !== 'GET') {
+ if (request.method !== 'GET' || !this.isCacheable(request.urlWithParams)) {
return next.handle(request);
}
diff --git a/src/app/core/shell/shell.component.html b/src/app/core/shell/shell.component.html
index 3351e3022..27da072b6 100644
--- a/src/app/core/shell/shell.component.html
+++ b/src/app/core/shell/shell.component.html
@@ -65,6 +65,14 @@
ESC Show Header
}
+
+ @else if (currentUrl.includes('/fd-dashboard')) {
+
+ }
@else if (currentUrl.includes('/clerk/')) {
+
{{ 'labels.inputs.Apply Penal Interest (less)' | translate }}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts
index bce8468a1..d4552f9d1 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-stepper/fixed-deposit-account-maturity-terms-step/fixed-deposit-account-maturity-terms-step.component.ts
@@ -11,6 +11,7 @@ import { Component, Input, OnChanges, SimpleChanges, inject } from '@angular/cor
import { UntypedFormGroup, UntypedFormBuilder, UntypedFormControl, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatIcon } from '@angular/material/icon';
+import { MatIconButton } from '@angular/material/button';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
/**
@@ -25,7 +26,8 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
imports: [
...STANDALONE_SHARED_IMPORTS,
MatCheckbox,
- MatIcon
+ MatIcon,
+ MatIconButton
]
})
export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges {
@@ -53,9 +55,12 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
preClosurePenalInterestOnTypeId: ['']
});
this.closureSettingsForm.get('maturityInstructionId').valueChanges.subscribe((value: number) => {
- // 200 = Transfer to Savings -- Withdraw Deposit (100) needs no target account, and both
- // Reinvest options (300/400) open a brand-new FD instead, so they ignore this field too.
- if (value === 200) {
+ // 200 = Transfer to Savings -- the whole matured balance goes here. 400 = Reinvest Principal
+ // Only -- the principal rolls into a new FD, but the leftover interest still needs the same
+ // kind of target so it isn't left stranded on the closed account (a real gap the account used
+ // to silently hit before this field also asked for it here). Reinvest Principal + Interest
+ // (300) rolls the whole balance into the same account, so it never needs a target at all.
+ if (value === 200 || value === 400) {
this.closureSettingsForm.addControl(
'transferToSavingsId',
new UntypedFormControl(this.linkedSavingsAccountId ?? '', Validators.required)
@@ -91,15 +96,48 @@ export class FixedDepositAccountMaturityTermsStepComponent implements OnChanges
});
}
}
+ // Editing an existing account: its own already-saved Maturity Instructions choice lives on
+ // `fixedDepositsAccountTemplate` (the real account+template GET, FixedDepositAccountData's
+ // `onAccountClosure`) -- NOT on `fixedDepositsAccountProductTemplate`, which is only the blank
+ // per-product default template refetched from the Product dropdown's own productId (via
+ // getFixedDepositsAccountTemplate(clientId, productId)) and carries no account-specific data at
+ // all. This was never read from the right place before, so Edit always opened onto a blank
+ // picker even for an account that really does have an instruction saved. `.dirty` guards it the
+ // same way the penal-interest fields above do, so a maker's own in-progress edit here is never
+ // silently overwritten if this input fires again mid-edit.
+ if (this.fixedDepositsAccountTemplate && !this.closureSettingsForm.get('maturityInstructionId').dirty) {
+ this.closureSettingsForm
+ .get('maturityInstructionId')
+ ?.setValue(this.fixedDepositsAccountTemplate.onAccountClosure?.id ?? '');
+ const transferControl = this.closureSettingsForm.get('transferToSavingsId');
+ if (transferControl) {
+ transferControl.setValue(
+ this.fixedDepositsAccountTemplate.transferToSavingsId ?? this.linkedSavingsAccountId ?? ''
+ );
+ }
+ }
}
- /** The linked-savings account for the transfer-to-savings maturity instruction, once picked. */
+ /** The linked-savings account for the transfer-to-savings / reinvest-principal-only-interest
+ * maturity instruction, once picked. */
get selectedTransferAccount(): any {
const id = this.closureSettingsForm.value.transferToSavingsId;
if (!id) return null;
return this.savingsAccountsData.find((account: any) => account.id === id) ?? null;
}
+ get transferAccountCardTitle(): string {
+ return this.closureSettingsForm.value.maturityInstructionId === 400
+ ? 'Interest payout account'
+ : 'Transfer to savings account';
+ }
+
+ get transferAccountCardSubtitle(): string {
+ return this.closureSettingsForm.value.maturityInstructionId === 400
+ ? 'The principal renews into a new FD; interest earned is paid out to this account.'
+ : 'Maturity proceeds will be credited to this account.';
+ }
+
clearTransferAccount(): void {
this.closureSettingsForm.get('transferToSavingsId')?.setValue('');
}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html
index 659f79528..94cd1ea54 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.html
@@ -8,11 +8,24 @@
-
-
-
{{ 'labels.heading.Payment' | translate }}
-
Process this Fixed Deposit now -- works whether it's Active or Matured.
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss
index 48967437c..c6c8920ad 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.scss
@@ -6,291 +6,466 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-$navy: #0d1b2a;
-$slate: #475569;
-$muted: #64748b;
-$border: #e2e8f0;
-$soft: #f8fafc;
-$blue: #1e40af;
-$blue-soft: #eff6ff;
-$green: #166534;
-$green-soft: #f0fdf4;
+/**
+ * Payment / Renewal Dialog Styles
+ * CBSNexora Core Banking System
+ */
.pmt {
display: flex;
flex-direction: column;
- min-height: 320px;
- max-height: 85vh;
+ height: 100%;
+ max-height: 92vh;
+ box-sizing: border-box;
+ font-family:
+ 'DM Sans',
+ system-ui,
+ -apple-system,
+ sans-serif;
+ color: #0f172a;
+ border-radius: 16px;
overflow: hidden;
- margin: -24px;
+ box-shadow: 0 24px 48px -12px rgb(15 23 42 / 25%);
}
.pmt__header {
- display: flex;
- align-items: flex-start;
- gap: 14px;
- padding: 22px 28px 18px;
- background: linear-gradient(135deg, #1e3a8a 0%, #1e40af 55%, #4338ca 100%);
- color: #fff;
- flex: 0 0 auto;
-}
-
-.pmt__header-ico {
- width: 40px;
- height: 40px;
- min-width: 40px;
- border-radius: 10px;
- background: rgb(255 255 255 / 15%);
display: flex;
align-items: center;
- justify-content: center;
- font-size: 18px;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 26px 30px;
+ background: linear-gradient(135deg, #1e3a8a 0%, #1d4ed8 55%, #2563eb 100%);
+ color: #fff;
+ flex-shrink: 0;
+ position: relative;
+ overflow: hidden;
+
+ &::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ background: radial-gradient(circle at 88% -20%, rgb(255 255 255 / 22%), transparent 55%);
+ pointer-events: none;
+ }
+
+ &-left {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ position: relative;
+ z-index: 1;
+ }
+
+ &-ico {
+ width: 48px;
+ height: 48px;
+ border-radius: 14px;
+ background: rgb(255 255 255 / 16%);
+ border: 1px solid rgb(255 255 255 / 25%);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 20px;
+ color: #fff;
+ flex-shrink: 0;
+ }
+
+ &-text {
+ display: flex;
+ flex-direction: column;
+ }
+
+ &-close {
+ position: relative;
+ z-index: 1;
+ background: rgb(255 255 255 / 14%);
+ border: none;
+ color: #fff;
+ width: 34px;
+ height: 34px;
+ border-radius: 50%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ flex-shrink: 0;
+
+ &:hover:not(:disabled) {
+ background: rgb(255 255 255 / 28%);
+ transform: rotate(90deg);
+ }
+
+ &:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+ }
+ }
}
.pmt__title {
margin: 0;
- font-size: 19px;
- font-weight: 700;
+ font-size: 20px;
+ font-weight: 800;
+ color: #fff;
+ line-height: 1.25;
+ letter-spacing: -0.01em;
}
.pmt__subtitle {
- margin: 4px 0 0;
- font-size: 12.5px;
- color: rgb(255 255 255 / 82%);
+ margin: 5px 0 0;
+ font-size: 13px;
+ color: rgb(255 255 255 / 88%);
line-height: 1.4;
}
.pmt__content {
- padding: 22px 28px 8px !important;
+ padding: 26px 30px !important;
overflow-y: auto;
flex: 1 1 auto;
+ background: #fbfcfe;
+
+ mat-form-field {
+ width: 100%;
+ }
}
.pmt__loading {
display: flex;
justify-content: center;
align-items: center;
- min-height: 260px;
+ min-height: 240px;
}
.pmt__stat {
display: flex;
flex-direction: column;
- gap: 2px;
- background: $blue-soft;
- border: 1px solid #dbeafe;
- border-radius: 10px;
- padding: 12px 16px;
+ gap: 4px;
+ background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
+ border: 1px solid #bfdbfe;
+ border-radius: 14px;
+ padding: 18px 22px;
margin-bottom: 18px;
+
+ &-label {
+ font-size: 11.5px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.07em;
+ color: #1d4ed8;
+ }
+
+ &-value {
+ font-family: 'JetBrains Mono', 'DM Sans', monospace;
+ font-size: 28px;
+ font-weight: 800;
+ color: #0f172a;
+ letter-spacing: -0.01em;
+ }
}
-.pmt__stat-label {
- font-size: 11px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.05em;
- color: $blue;
-}
+.pmt__preview {
+ display: flex;
+ flex-direction: column;
+ background: #fff;
+ border: 1px solid #e2e8f0;
+ border-radius: 14px;
+ padding: 4px 18px;
+ margin: 0 0 18px;
-.pmt__stat-value {
- font-size: 22px;
- font-weight: 800;
- color: $navy;
- font-variant-numeric: tabular-nums;
-}
+ &-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 11px 0;
+ border-bottom: 1px solid #f1f5f9;
-.pmt__date {
- width: 100%;
+ &:last-child {
+ border-bottom: none;
+ }
+
+ &--highlight {
+ .pmt__preview-value {
+ color: #0d9488;
+ font-weight: 800;
+ }
+ }
+
+ &--total {
+ margin: 0 -18px;
+ padding: 14px 18px;
+ background: linear-gradient(135deg, #eff6ff 0%, #e0e9ff 100%);
+ border-bottom: none;
+ border-radius: 0 0 12px 12px;
+
+ .pmt__preview-label {
+ color: #1d4ed8;
+ font-weight: 700;
+ }
+
+ .pmt__preview-value {
+ color: #0f172a;
+ font-size: 18px;
+ font-weight: 800;
+ }
+ }
+ }
+
+ &-label {
+ font-size: 12.5px;
+ color: #64748b;
+ font-weight: 600;
+ }
+
+ &-value {
+ font-family: 'JetBrains Mono', 'DM Sans', monospace;
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ }
}
.pmt__section-label {
- font-size: 11.5px;
+ font-size: 12px;
font-weight: 700;
text-transform: uppercase;
- letter-spacing: 0.05em;
- color: $slate;
- margin: 4px 0 10px;
+ letter-spacing: 0.06em;
+ color: #475569;
+ margin: 10px 0 12px;
}
+/* Action Options Grid - 2 Equal Columns, comfortably wide now that the dialog itself is wider */
.pmt__options {
display: grid;
- grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
- gap: 10px;
- margin-bottom: 6px;
+ grid-template-columns: repeat(2, 1fr);
+ gap: 14px;
+ margin-bottom: 18px;
+
+ @media (width <= 560px) {
+ grid-template-columns: 1fr;
+ }
}
.pmt__option {
display: flex;
align-items: flex-start;
- gap: 10px;
+ gap: 14px;
text-align: left;
background: #fff;
- border: 1.5px solid $border;
- border-radius: 10px;
- padding: 12px 14px;
+ border: 1.5px solid #e2e8f0;
+ border-radius: 14px;
+ padding: 16px;
cursor: pointer;
font: inherit;
- transition:
- border-color 0.15s,
- background 0.15s,
- box-shadow 0.15s;
-}
+ transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
+ position: relative;
-.pmt__option:hover {
- border-color: #93c5fd;
- background: $blue-soft;
-}
+ &:hover {
+ border-color: #93c5fd;
+ background: #f8fafc;
+ box-shadow: 0 6px 16px rgb(15 23 42 / 6%);
+ transform: translateY(-1px);
+ }
-.pmt__option--active {
- border-color: $blue;
- background: $blue-soft;
- box-shadow: 0 0 0 1px $blue;
-}
+ &--active {
+ border-color: #2563eb;
+ background: #eff6ff;
+ box-shadow: 0 0 0 1.5px #2563eb;
-.pmt__option-ico {
- width: 30px;
- height: 30px;
- min-width: 30px;
- border-radius: 8px;
- background: $soft;
- color: $blue;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 13px;
-}
+ .pmt__option-ico {
+ background: #2563eb;
+ color: #fff;
+ border-color: #2563eb;
+ }
+ }
-.pmt__option--active .pmt__option-ico {
- background: $blue;
- color: #fff;
-}
+ &-ico {
+ width: 38px;
+ height: 38px;
+ min-width: 38px;
+ border-radius: 10px;
+ background: #f1f5f9;
+ border: 1px solid #e2e8f0;
+ color: #2563eb;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ font-size: 15px;
+ flex-shrink: 0;
+ transition: all 0.2s ease;
+ }
-.pmt__option-text {
- display: flex;
- flex-direction: column;
- gap: 2px;
-}
+ // Distinct accent per option while unselected, purely presentational -- helps the four cards read
+ // apart at a glance. Selecting a card always fills its icon blue (see &--active above), matching the
+ // blue selection border/check regardless of which option it is, rather than mixing selection-blue
+ // with the option's own accent.
+ &:not(.pmt__option--active):nth-child(2) .pmt__option-ico {
+ color: #7c3aed;
+ }
-.pmt__option-title {
- font-size: 13px;
- font-weight: 700;
- color: $navy;
-}
+ &:not(.pmt__option--active):nth-child(3) .pmt__option-ico {
+ color: #0d9488;
+ }
-.pmt__option-desc {
- font-size: 11px;
- color: $muted;
- line-height: 1.35;
-}
+ &:not(.pmt__option--active):nth-child(4) .pmt__option-ico {
+ color: #d97706;
+ }
-.pmt__option:focus-visible {
- outline: 2px solid $blue;
- outline-offset: 2px;
-}
+ &-text {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ flex: 1;
+ }
-.pmt__option-check {
- margin-left: auto;
- color: $blue;
- font-size: 13px;
+ &-title {
+ font-size: 14px;
+ font-weight: 700;
+ color: #0f172a;
+ line-height: 1.3;
+ }
+
+ &-desc {
+ font-size: 12px;
+ color: #64748b;
+ line-height: 1.4;
+ }
+
+ &-check {
+ color: #2563eb;
+ font-size: 17px;
+ position: absolute;
+ top: 14px;
+ right: 14px;
+ }
}
.pmt__option-error {
color: #b91c1c;
font-size: 12px;
- margin: 4px 0 14px;
-}
-
-.pmt__error {
- display: flex;
- align-items: flex-start;
- gap: 4px;
- background: #fef2f2;
- border: 1px solid #fecaca;
- border-radius: 8px;
- color: #b91c1c;
- font-size: 12.5px;
- line-height: 1.45;
- padding: 10px 14px;
- margin: 4px 0 8px;
-}
-
-.pmt__hint {
- display: flex;
- align-items: flex-start;
- gap: 4px;
- background: $soft;
- border: 1px solid $border;
- border-radius: 8px;
- color: $slate;
- font-size: 12px;
- line-height: 1.45;
- padding: 9px 13px;
- margin: 0 0 10px;
-}
-
-.pmt__hint--warn {
- background: #fffbeb;
- border-color: #fde68a;
- color: #92400e;
+ font-weight: 600;
+ margin: -10px 0 14px;
}
.pmt__split {
display: flex;
align-items: center;
- gap: 12px;
- background: $soft;
+ gap: 16px;
+ background: #f8fafc;
border: 1px dashed #cbd5e1;
- border-radius: 10px;
- padding: 14px 16px 0;
- margin: 4px 0 8px;
+ border-radius: 14px;
+ padding: 18px;
+ margin: 10px 0 18px;
flex-wrap: wrap;
+
+ &-input {
+ flex: 1 1 220px;
+ }
+
+ &-arrow {
+ color: #94a3b8;
+ font-size: 17px;
+ }
+
+ &-remainder {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ background: #f0fdf4;
+ border: 1px solid #bbf7d0;
+ border-radius: 10px;
+ padding: 12px 16px;
+ flex: 1 1 220px;
+
+ &-label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+ color: #166534;
+ }
+
+ &-value {
+ font-family: 'JetBrains Mono', 'DM Sans', monospace;
+ font-size: 19px;
+ font-weight: 800;
+ color: #166534;
+ }
+ }
}
-.pmt__split-input {
- flex: 1 1 180px;
- min-width: 160px;
-}
-
-.pmt__split-arrow {
- color: $muted;
- font-size: 14px;
- margin-bottom: 20px;
-}
-
-.pmt__split-remainder {
+.pmt__hint {
display: flex;
- flex-direction: column;
- gap: 2px;
- background: $green-soft;
- border: 1px solid #bbf7d0;
- border-radius: 8px;
- padding: 8px 14px;
- margin-bottom: 14px;
- flex: 1 1 180px;
- min-width: 160px;
+ align-items: flex-start;
+ gap: 10px;
+ background: #f0f9ff;
+ border: 1px solid #bae6fd;
+ border-left: 4px solid #0284c7;
+ border-radius: 10px;
+ color: #0369a1;
+ font-size: 13px;
+ line-height: 1.5;
+ padding: 12px 16px;
+ margin: 0 0 18px;
+
+ &--warn {
+ background: #fffbeb;
+ border-color: #fde68a;
+ border-left-color: #d97706;
+ color: #b45309;
+ }
}
-.pmt__split-remainder-label {
- font-size: 10.5px;
- font-weight: 700;
- text-transform: uppercase;
- letter-spacing: 0.04em;
- color: $green;
-}
-
-.pmt__split-remainder-value {
- font-size: 16px;
- font-weight: 800;
- color: $green;
- font-variant-numeric: tabular-nums;
-}
-
-.pmt__content mat-form-field {
- width: 100%;
+.pmt__error {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+ background: #fef2f2;
+ border: 1px solid #fecaca;
+ border-left: 4px solid #ef4444;
+ border-radius: 10px;
+ color: #b91c1c;
+ font-size: 13px;
+ padding: 12px 16px;
+ margin: 10px 0;
}
.pmt__actions {
- padding: 14px 28px 20px !important;
- border-top: 1px solid $border;
- flex: 0 0 auto;
+ padding: 18px 30px !important;
+ border-top: 1px solid #e2e8f0;
+ background: #fff;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ gap: 14px;
+ flex-shrink: 0;
+
+ .pmt__btn-cancel {
+ border-radius: 10px;
+ font-weight: 600;
+ color: #475569;
+ }
+
+ .pmt__btn-submit {
+ border-radius: 10px;
+ font-weight: 700;
+ padding: 0 26px;
+ height: 42px;
+ background: linear-gradient(135deg, #1d4ed8 0%, #2563eb 100%);
+ color: #fff;
+ box-shadow: 0 8px 20px rgb(37 99 235 / 30%);
+ transition: all 0.2s ease;
+
+ &:hover:not(:disabled) {
+ box-shadow: 0 10px 24px rgb(37 99 235 / 40%);
+ transform: translateY(-1px);
+ }
+
+ &:disabled {
+ background: #cbd5e1;
+ box-shadow: none;
+ color: #64748b;
+ }
+ }
}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts
index cd433f83c..521656fa8 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/payment-dialog/payment-dialog.component.ts
@@ -24,15 +24,26 @@ import { UntypedFormBuilder, UntypedFormGroup, UntypedFormControl, ValidatorFn,
/** Custom Services */
import { FixedDepositsService } from '../../../fixed-deposits.service';
import { SettingsService } from 'app/settings/settings.service';
+import { AlertService } from 'app/core/alert/alert.service';
import { Dates } from 'app/core/utils/dates';
import { FormatNumberPipe } from '../../../../../pipes/format-number.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { FdMaturityRow } from 'app/fd-dashboard/fd-dashboard.compute';
/**
* "Payment" dialog -- an always-available alternative to Premature Close (Active-only, no reinvest) and
* Close (Matured-only, no partial option). Works on an FD in either state and offers a fourth choice,
* Partial Reinvest, alongside the ones the backend's `DepositAccountOnClosureType` already had: the
* entered amount renews on this same account, the rest transfers to a savings account picked here.
+ *
+ * Doubles as the FD Dashboard's Renew action when opened with `data.dashboardRenewal` set: same form,
+ * same full set of closure types (nothing removed -- Transfer to Savings and Partial Reinvest work from
+ * the Dashboard too, not just the two Reinvest options). The only two differences in that mode: the
+ * Closed On Date is computed from the FD's grace window instead of picked freely (backdated to the
+ * original maturity date within grace, today once it's passed -- the picker is hidden, not just
+ * defaulted, so it can't accidentally be moved off that date), and submit() first calls
+ * `creditGapInterestIfPastGrace` when past grace, crediting the gap-period interest (at the linked
+ * account's rate) onto the FD before the normal `payment` command runs and reads its balance.
*/
@Component({
selector: 'mifosx-payment-dialog',
@@ -76,11 +87,18 @@ export class PaymentDialogComponent implements OnInit {
};
dialogRef = inject>(MatDialogRef);
- data: { accountId: number | string; accountBalance?: number; maturityDate?: any } = inject(MAT_DIALOG_DATA);
+ data: {
+ accountId: number | string;
+ accountBalance?: number;
+ maturityDate?: any;
+ /** Present only when opened from the FD Dashboard's Renew action. */
+ dashboardRenewal?: { row: FdMaturityRow };
+ } = inject(MAT_DIALOG_DATA);
private formBuilder = inject(UntypedFormBuilder);
private fixedDepositsService = inject(FixedDepositsService);
private settingsService = inject(SettingsService);
private dateUtils = inject(Dates);
+ private alertService = inject(AlertService);
paymentForm: UntypedFormGroup;
/** Loaded once, on open, from the same closure template the existing Close action already uses. */
@@ -93,6 +111,16 @@ export class PaymentDialogComponent implements OnInit {
submitError: string = null;
maxDate: Date;
+ /** Whether gap-interest still needs crediting before this submits -- only ever true once, right
+ * after open; cleared as soon as `submit()` has credited it, so a retried submit doesn't double-post. */
+ private gapInterestPending = false;
+
+ /** Read-only numbers (current balance, linked account's rate, gap days, gap-interest amount,
+ * projected new balance) fetched once on open in dashboard-renewal mode, shown above the Action
+ * cards so staff see exactly what a late renewal is about to credit before confirming anything. */
+ renewalPreview: any = null;
+ renewalPreviewLoading = false;
+
ngOnInit(): void {
this.maxDate = this.settingsService.businessDate;
this.paymentForm = this.formBuilder.group({
@@ -106,6 +134,34 @@ export class PaymentDialogComponent implements OnInit {
],
note: ['']
});
+
+ // Fetched every time this dialog opens, not just from the FD Dashboard -- Payment is now only ever
+ // offered once an FD has Matured (see FixedDepositsButtonsConfiguration), so a plain Payment on it
+ // is exactly the same "late renewal" scenario the Dashboard's Renew action handles, and needs the
+ // exact same date-locking/gap-interest treatment, or an FD closed this way instead of from the
+ // Dashboard would silently skip crediting interest it genuinely earned.
+ this.renewalPreviewLoading = true;
+ this.fixedDepositsService.getFdRenewalPreview(this.data.accountId).subscribe({
+ next: (preview: any) => {
+ this.renewalPreview = preview;
+ this.renewalPreviewLoading = false;
+ if (preview?.isMatured) {
+ // Computed from the grace window, never picked -- the date field stays in the form (the
+ // generic submit path and reinvestCap/processingOnOrAfterMaturity both key off it) but its
+ // picker is hidden once matured (see the template), so it can't be moved off this value.
+ this.paymentForm
+ .get('closedOnDate')
+ .setValue(preview.withinGrace ? new Date(preview.maturityDate) : this.maxDate);
+ this.gapInterestPending = !preview.withinGrace;
+ }
+ },
+ // Non-fatal -- Confirm still works from here (backend re-derives the same numbers on submit),
+ // only the numeric preview and its date-locking are unavailable, not the action itself.
+ error: () => {
+ this.renewalPreviewLoading = false;
+ }
+ });
+
this.paymentForm.get('onAccountClosureId').valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
// The cap depends on whether the chosen date is on or after maturity, so a date change can make an
// already-entered amount valid or invalid. Without this the amount keeps its verdict from the old date.
@@ -136,6 +192,31 @@ export class PaymentDialogComponent implements OnInit {
});
}
+ get isDashboardRenewalMode(): boolean {
+ return !!this.data.dashboardRenewal;
+ }
+
+ get dashboardRenewalRow(): FdMaturityRow | null {
+ return this.data.dashboardRenewal?.row ?? null;
+ }
+
+ /** True once the loaded preview confirms this FD has actually Matured -- which, since Payment is
+ * only ever offered on a Matured account, is every real open of this dialog now, from either entry
+ * point. Drives the date-locking and gap-interest preview/crediting identically either way. */
+ get isMaturedRenewal(): boolean {
+ return !!this.renewalPreview?.isMatured;
+ }
+
+ /** What clicking Confirm is actually about to do -- shown above the (hidden-picker) date once
+ * matured, since the date itself is no longer visible to explain it. */
+ get maturedRenewalTimingText(): string {
+ if (!this.renewalPreview) return '';
+ return this.renewalPreview.withinGrace
+ ? `Backdated to its original maturity date (${this.renewalPreview.maturityDate}), at the currently active interest rate.`
+ : `This FD's grace period has passed -- gap-period interest (for the days since maturity) will be ` +
+ `credited at the linked account's rate first, then this renews from today.`;
+ }
+
/**
* The amount being split, and the bound the entered partial amount is checked against.
*
@@ -259,6 +340,31 @@ export class PaymentDialogComponent implements OnInit {
}
submit(): void {
+ if (this.gapInterestPending) {
+ this.submitting = true;
+ this.submitError = null;
+ this.fixedDepositsService.creditGapInterestIfPastGrace(this.data.accountId).subscribe({
+ next: () => {
+ this.gapInterestPending = false;
+ this.submitGenericPayment();
+ },
+ error: (error: any) => {
+ this.submitting = false;
+ this.submitError =
+ error?.error?.defaultUserMessage ??
+ error?.error?.errors?.[0]?.defaultUserMessage ??
+ 'Could not credit gap-period interest. Please review the details and try again.';
+ }
+ });
+ return;
+ }
+ this.submitGenericPayment();
+ }
+
+ /** The same `command=payment` submission every non-Dashboard call to this dialog already used --
+ * Dashboard-renewal mode reaches it too, once any gap-interest pre-step above has cleared, so every
+ * closure type behaves identically regardless of where the dialog was opened from. */
+ private submitGenericPayment(): void {
const formValue = { ...this.paymentForm.value };
const locale = this.settingsService.language.code;
const dateFormat = this.settingsService.dateFormat;
@@ -271,7 +377,16 @@ export class PaymentDialogComponent implements OnInit {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.data.accountId as string, 'payment', data)
.subscribe({
- next: () => {
+ next: (response: any) => {
+ // Parked in the Checker Inbox rather than executed -- see the same handling in
+ // PrematureCloseDialogComponent for why an HTTP 200 here can still mean "not applied yet".
+ if (response?.rollbackTransaction) {
+ this.alertService.alert({
+ type: 'Success',
+ message:
+ 'This payment has been submitted for approval. The deposit is unchanged until a checker approves it.'
+ });
+ }
this.dialogRef.close({ confirm: true });
},
// Keep the dialog open with the entered values and say what went wrong, rather than leaving a
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html
new file mode 100644
index 000000000..cc6736b60
--- /dev/null
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/custom-dialogs/premature-close-dialog/premature-close-dialog.component.html
@@ -0,0 +1,282 @@
+
+
+
+ * The premature amount is date-dependent (unlike Payment's one-shot template load): every change to
+ * Closed On Date re-calls `calculatePrematureAmount`, same as the full-page form this replaces did.
+ */
+@Component({
+ selector: 'mifosx-premature-close-dialog',
+ templateUrl: './premature-close-dialog.component.html',
+ styleUrls: ['./premature-close-dialog.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ CdkScrollable,
+ MatDialogContent,
+ MatDialogActions,
+ MatDialogClose,
+ CdkTextareaAutosize,
+ FaIconComponent,
+ FormatNumberPipe,
+ GlAccountSelectorComponent
+ ]
+})
+export class PrematureCloseDialogComponent implements OnInit {
+ /** The `onAccountClosureId` codes Premature Close can offer -- same codes Payment/Close use. */
+ private static readonly WITHDRAW_DEPOSIT_ID = 100;
+ private static readonly TRANSFER_TO_SAVINGS_ID = 200;
+ private static readonly TRANSFER_TO_GL_ID = 600;
+
+ private static readonly ACTION_ICONS: Record = {
+ [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'money-bill-wave',
+ [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'wallet',
+ [PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'book'
+ };
+
+ private static readonly ACTION_DESCRIPTIONS: Record = {
+ [PrematureCloseDialogComponent.WITHDRAW_DEPOSIT_ID]: 'Pay out the full balance in cash',
+ [PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID]: 'Move the full balance to another savings account',
+ [PrematureCloseDialogComponent.TRANSFER_TO_GL_ID]: 'Post the full balance to a GL account'
+ };
+
+ /**
+ * Mirrors the backend's PrematureClosureReasonType values exactly -- see that enum for why these
+ * four (everything but Normal) always waive the penalty by RBI policy rather than at the maker's
+ * discretion. `gatedBy` is the product-facility flag (from `data`) that must be true for the option
+ * to even be offered; `null` means always offered. Deceased Settlement / Nominee-Legal Heir Closure
+ * are gated because they are the product's own opt-in facilities (see SavingsProduct
+ * deceasedSettlement/noPenaltyNomineeHeirClosure) -- a product that never enabled one shouldn't let
+ * a maker pick it to bypass a penalty that product's terms don't actually exempt.
+ */
+ private static readonly NORMAL_REASON_ID = 100;
+ private static readonly ALL_CLOSURE_REASONS: {
+ id: number;
+ label: string;
+ autoWaived: boolean;
+ gatedBy: 'deceasedSettlement' | 'noPenaltyNomineeHeirClosure' | null;
+ }[] = [
+ { id: 100, label: 'Normal Premature Closure', autoWaived: false, gatedBy: null },
+ { id: 200, label: 'Premature Extension / Renewal', autoWaived: true, gatedBy: null },
+ { id: 300, label: 'Conversion to Another Term Deposit', autoWaived: true, gatedBy: null },
+ { id: 400, label: 'Deceased Settlement', autoWaived: true, gatedBy: 'deceasedSettlement' },
+ { id: 500, label: 'Nominee / Legal Heir Closure', autoWaived: true, gatedBy: 'noPenaltyNomineeHeirClosure' }
+ ];
+
+ dialogRef = inject>(MatDialogRef);
+ data: {
+ accountId: number | string;
+ accountNo?: string;
+ depositAmount?: number;
+ nominalAnnualInterestRate?: number;
+ preClosurePenalApplicable?: boolean;
+ preClosurePenalInterest?: number;
+ preClosurePenalInterestOnType?: { value: string };
+ deceasedSettlement?: boolean;
+ noPenaltyNomineeHeirClosure?: boolean;
+ activatedOnDate?: any;
+ maturityDate?: any;
+ currentBalance?: number;
+ } = inject(MAT_DIALOG_DATA);
+ private formBuilder = inject(UntypedFormBuilder);
+ private fixedDepositsService = inject(FixedDepositsService);
+ private settingsService = inject(SettingsService);
+ private accountingService = inject(AccountingService);
+ private alertService = inject(AlertService);
+ private dateUtils = inject(Dates);
+
+ prematureCloseForm: UntypedFormGroup;
+ onAccountClosureOptions: any[] = [];
+ savingsAccountsData: any[] = [];
+ /** Postable GL accounts, loaded once -- only read when the GL action is chosen. */
+ glAccountsData: GLAccount[] = [];
+ maturityAmount: number = null;
+ /** The rate actually resolved for THIS preview by the backend (actual-period vs contracted,
+ * penalty-deducted or not) -- distinct from data.nominalAnnualInterestRate, which is always the
+ * account's unmodified contracted rate. Read fresh on every recalculation. */
+ effectiveInterestRate: number = null;
+ loadingAmount = true;
+ loadFailed = false;
+ submitting = false;
+ submitError: string = null;
+ minDate = new Date(2000, 0, 1);
+ maxDate: Date;
+
+ ngOnInit(): void {
+ this.maxDate = this.settingsService.businessDate;
+ this.prematureCloseForm = this.formBuilder.group({
+ closedOnDate: [
+ this.maxDate,
+ Validators.required
+ ],
+ // Both always present (not added conditionally like onAccountClosureId below) so submit()
+ // always carries a value, even for a product with no penalty configured at all -- the backend
+ // ignores them there anyway, since there is nothing to waive.
+ prematureClosureReasonId: [PrematureCloseDialogComponent.NORMAL_REASON_ID],
+ waivePreClosurePenalty: [false]
+ });
+
+ this.prematureCloseForm
+ .get('closedOnDate')
+ .valueChanges.subscribe((date: Date) => this.calculatePrematureAmount(date));
+ // Waiving/un-waiving recalculates the preview the same way changing the date does -- the maker
+ // should see the actual amount they're about to confirm, not the penalty-applied one, the moment
+ // they tick the box.
+ this.prematureCloseForm
+ .get('waivePreClosurePenalty')
+ .valueChanges.subscribe(() => this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value));
+ // Picking an RBI-mandated waiver reason forces the checkbox on (see onClosureReasonChange) and,
+ // like it, recalculates the preview so the amount shown always matches the reason selected.
+ this.prematureCloseForm
+ .get('prematureClosureReasonId')
+ .valueChanges.subscribe((id: number) => this.onClosureReasonChange(id));
+ // Same date the form already starts with -- without this the amount/options never load unless the
+ // maker happens to touch the date picker, which the full-page form this replaces required too.
+ this.calculatePrematureAmount(this.maxDate);
+ }
+
+ /** The reason list this account's product actually allows -- see ALL_CLOSURE_REASONS' `gatedBy`. */
+ get closureReasonOptions(): { id: number; label: string; autoWaived: boolean }[] {
+ return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.filter(
+ (reason) => !reason.gatedBy || this.data[reason.gatedBy]
+ );
+ }
+
+ /** True when the selected reason waives the penalty by policy, not maker choice -- the checkbox is
+ * hidden in favour of a plain statement of that fact when this is true (see the template). */
+ get isAutoWaivedReason(): boolean {
+ return !!this.selectedReason?.autoWaived;
+ }
+
+ get selectedReasonLabel(): string {
+ return this.selectedReason?.label ?? '';
+ }
+
+ private get selectedReason(): { id: number; label: string; autoWaived: boolean } | undefined {
+ const id = this.prematureCloseForm?.get('prematureClosureReasonId')?.value;
+ return PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((reason) => reason.id === id);
+ }
+
+ private onClosureReasonChange(reasonId: number): void {
+ const reason = PrematureCloseDialogComponent.ALL_CLOSURE_REASONS.find((r) => r.id === reasonId);
+ if (reason?.autoWaived) {
+ // emitEvent: false -- the explicit calculatePrematureAmount call below already covers this
+ // change; without it, this setValue would also fire waivePreClosurePenalty's own subscription
+ // and the amount would be calculated twice for one reason change.
+ this.prematureCloseForm.get('waivePreClosurePenalty').setValue(true, { emitEvent: false });
+ }
+ this.calculatePrematureAmount(this.prematureCloseForm.get('closedOnDate').value);
+ }
+
+ private calculatePrematureAmount(date: Date): void {
+ this.loadingAmount = true;
+ this.loadFailed = false;
+ const locale = this.settingsService.language.code;
+ const dateFormat = this.settingsService.dateFormat;
+ const data = {
+ closedOnDate: this.dateUtils.formatDate(date, dateFormat),
+ dateFormat,
+ locale,
+ prematureClosureReasonId: this.prematureCloseForm.get('prematureClosureReasonId')?.value,
+ waivePreClosurePenalty: !!this.prematureCloseForm.get('waivePreClosurePenalty')?.value
+ };
+ this.fixedDepositsService
+ .executeFixedDepositsAccountCommand(this.data.accountId as string, 'calculatePrematureAmount', data)
+ .subscribe({
+ next: (response: any) => {
+ this.savingsAccountsData = response.savingsAccounts ?? [];
+ this.onAccountClosureOptions = response.onAccountClosureOptions ?? [];
+ this.maturityAmount = response.maturityAmount;
+ this.effectiveInterestRate = response.nominalAnnualInterestRate ?? null;
+ this.loadingAmount = false;
+ if (!this.prematureCloseForm.contains('onAccountClosureId')) {
+ this.prematureCloseForm.addControl('onAccountClosureId', new UntypedFormControl('', Validators.required));
+ this.prematureCloseForm.addControl('note', new UntypedFormControl(''));
+ this.prematureCloseForm
+ .get('onAccountClosureId')
+ .valueChanges.subscribe((id: number) => this.onClosureTypeChange(id));
+ }
+ },
+ // Without this the spinner would run forever on a failed load, leaving Cancel as the only way out.
+ error: () => {
+ this.loadingAmount = false;
+ this.loadFailed = true;
+ }
+ });
+ }
+
+ /** Adds/removes the destination control to match what the chosen type needs. */
+ private onClosureTypeChange(id: number): void {
+ const toSavings = id === PrematureCloseDialogComponent.TRANSFER_TO_SAVINGS_ID;
+ const toGl = id === PrematureCloseDialogComponent.TRANSFER_TO_GL_ID;
+
+ if (toSavings && !this.prematureCloseForm.contains('toSavingsAccountId')) {
+ this.prematureCloseForm.addControl('toSavingsAccountId', new UntypedFormControl('', Validators.required));
+ } else if (!toSavings) {
+ this.prematureCloseForm.removeControl('toSavingsAccountId');
+ }
+
+ if (toGl) {
+ if (!this.prematureCloseForm.contains('toGlAccountId')) {
+ this.prematureCloseForm.addControl('toGlAccountId', new UntypedFormControl('', Validators.required));
+ }
+ this.loadGlAccounts();
+ } else {
+ this.prematureCloseForm.removeControl('toGlAccountId');
+ }
+
+ // Both destinations carry the description through -- to the account transfer, or to the narration on
+ // the journal entry that credits the GL account.
+ if ((toSavings || toGl) && !this.prematureCloseForm.contains('transferDescription')) {
+ this.prematureCloseForm.addControl('transferDescription', new UntypedFormControl(''));
+ } else if (!toSavings && !toGl) {
+ this.prematureCloseForm.removeControl('transferDescription');
+ }
+ }
+
+ /** Fetched on first use rather than with the dialog, since most closures never pick the GL action. */
+ private loadGlAccounts(): void {
+ if (this.glAccountsData.length) {
+ return;
+ }
+ this.accountingService.getGlAccounts().subscribe((accounts: GLAccount[]) => {
+ this.glAccountsData = accounts ?? [];
+ });
+ }
+
+ /** The GL selector takes a control instance, not a control name. */
+ get toGlAccountControl(): UntypedFormControl {
+ return this.prematureCloseForm.get('toGlAccountId') as UntypedFormControl;
+ }
+
+ /** The gap between the deposit and the computed maturity amount -- the actual interest this premature
+ * closure earns, at whatever rate (contracted, or the lower pre-closure-penalty one below) applied. */
+ get interestEarned(): number | null {
+ if (this.maturityAmount === null || this.maturityAmount === undefined || !this.data.depositAmount) {
+ return null;
+ }
+ return Number(this.maturityAmount) - Number(this.data.depositAmount);
+ }
+
+ get activatedOnDateDisplay(): string {
+ return this.formatFullDate(this.data.activatedOnDate);
+ }
+
+ get maturityDateDisplay(): string {
+ return this.formatFullDate(this.data.maturityDate);
+ }
+
+ /**
+ * The date every "as of" figure in this dialog is measured against -- the maker's own chosen Premature
+ * Close Date, falling back to Fineract's configured Business Date. Deliberately NOT the real-world
+ * clock (`new Date()`): Fineract's business date can lag behind the calendar (a bank's own admin
+ * advances it), and the backend's interest calculation is measured from THIS date, not today's actual
+ * date. Using the real clock here previously showed a "days elapsed" figure that disagreed with the
+ * interest the backend actually computed for the same closure -- both must read off the same clock.
+ */
+ private get referenceDate(): Date {
+ const picked = this.prematureCloseForm?.get('closedOnDate')?.value;
+ return picked instanceof Date ? picked : this.maxDate;
+ }
+
+ /** Days from the current term's start to the chosen closure date -- 0 if the date is missing or the
+ * closure date is somehow before it, rather than a negative. */
+ get daysElapsed(): number | null {
+ const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
+ if (!start) {
+ return null;
+ }
+ return Math.max(0, this.dateUtils.calculateDiff(start, this.referenceDate));
+ }
+
+ /** Days from the chosen closure date to the scheduled maturity date -- 0 once matured/overdue rather
+ * than negative, since this dialog is for a still-Active account closing early, not a matured one. */
+ get daysRemainingToMaturity(): number | null {
+ const maturity = this.data.maturityDate ? this.dateUtils.parseDate(this.data.maturityDate) : null;
+ if (!maturity) {
+ return null;
+ }
+ return Math.max(0, this.dateUtils.calculateDiff(this.referenceDate, maturity));
+ }
+
+ /** Elapsed tenure as Years/Months/Days, the way a real passbook states it, rather than a raw day
+ * count that is hard to read for a multi-year FD. */
+ get tenureElapsedDisplay(): string {
+ const start = this.data.activatedOnDate ? this.dateUtils.parseDate(this.data.activatedOnDate) : null;
+ if (!start) {
+ return '—';
+ }
+ const { years, months, days } = PrematureCloseDialogComponent.yearsMonthsDaysBetween(start, this.referenceDate);
+ const parts: string[] = [];
+ if (years > 0) {
+ parts.push(`${years} yr${years === 1 ? '' : 's'}`);
+ }
+ if (months > 0) {
+ parts.push(`${months} mo${months === 1 ? '' : 's'}`);
+ }
+ if (days > 0 || parts.length === 0) {
+ parts.push(`${days} day${days === 1 ? '' : 's'}`);
+ }
+ return parts.join(', ');
+ }
+
+ private formatFullDate(value: any): string {
+ if (!value) {
+ return '—';
+ }
+ const parsed = this.dateUtils.parseDate(value);
+ // "28 September 2026" -- day/month-name/year, so this reads unambiguously regardless of the
+ // account's own configured short date format elsewhere in the app.
+ return this.dateUtils.formatDate(parsed, 'd MMMM y');
+ }
+
+ private static yearsMonthsDaysBetween(start: Date, end: Date): { years: number; months: number; days: number } {
+ let years = end.getFullYear() - start.getFullYear();
+ let months = end.getMonth() - start.getMonth();
+ let days = end.getDate() - start.getDate();
+ if (days < 0) {
+ months -= 1;
+ const daysInPreviousMonth = new Date(end.getFullYear(), end.getMonth(), 0).getDate();
+ days += daysInPreviousMonth;
+ }
+ if (months < 0) {
+ years -= 1;
+ months += 12;
+ }
+ return { years: Math.max(0, years), months: Math.max(0, months), days: Math.max(0, days) };
+ }
+
+ /** True when the chosen action needs a destination account but the customer has none to pick. */
+ get noDestinationAccounts(): boolean {
+ return (
+ !this.loadingAmount &&
+ this.prematureCloseForm?.contains('toSavingsAccountId') &&
+ this.savingsAccountsData.length === 0
+ );
+ }
+
+ iconFor(id: number): string {
+ return PrematureCloseDialogComponent.ACTION_ICONS[id] ?? 'circle';
+ }
+
+ descriptionFor(id: number): string {
+ return PrematureCloseDialogComponent.ACTION_DESCRIPTIONS[id] ?? '';
+ }
+
+ selectClosureType(id: number): void {
+ this.prematureCloseForm.get('onAccountClosureId').setValue(id);
+ this.prematureCloseForm.get('onAccountClosureId').markAsTouched();
+ }
+
+ submit(): void {
+ const formValue = { ...this.prematureCloseForm.value };
+ const locale = this.settingsService.language.code;
+ const dateFormat = this.settingsService.dateFormat;
+ if (formValue.closedOnDate instanceof Date) {
+ formValue.closedOnDate = this.dateUtils.formatDate(formValue.closedOnDate, dateFormat);
+ }
+ const data = { ...formValue, dateFormat, locale };
+ this.submitting = true;
+ this.submitError = null;
+ this.fixedDepositsService
+ .executeFixedDepositsAccountCommand(this.data.accountId as string, 'prematureClose', data)
+ .subscribe({
+ next: (response: any) => {
+ // A command on a maker-checker permission is parked in the Checker Inbox, not executed:
+ // the platform rolls its transaction back and still answers HTTP 200 with
+ // rollbackTransaction: true. Without saying so, the dialog closes and the account reloads
+ // unchanged -- which reads as "nothing happened" rather than "waiting for approval".
+ if (response?.rollbackTransaction) {
+ this.alertService.alert({
+ type: 'Success',
+ message:
+ 'Premature closure has been submitted for approval. The deposit is unchanged until a checker approves it.'
+ });
+ }
+ this.dialogRef.close({ confirm: true });
+ },
+ // Keep the dialog open with the entered values and say what went wrong, rather than leaving a
+ // permanently disabled Confirm behind whatever modal the global interceptor threw up.
+ error: (error: any) => {
+ this.submitting = false;
+ this.submitError =
+ error?.error?.defaultUserMessage ??
+ error?.error?.errors?.[0]?.defaultUserMessage ??
+ 'This account could not be prematurely closed. Please review the details and try again.';
+ }
+ });
+ }
+}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
index fefdb323d..088e31ec2 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposit-account-view.component.ts
@@ -17,6 +17,7 @@ import { CalculateInterestDialogComponent } from './custom-dialogs/calculate-int
import { PostInterestDialogComponent } from './custom-dialogs/post-interest-dialog/post-interest-dialog.component';
import { ToggleWithholdTaxDialogComponent } from './custom-dialogs/toggle-withhold-tax-dialog/toggle-withhold-tax-dialog.component';
import { PaymentDialogComponent } from './custom-dialogs/payment-dialog/payment-dialog.component';
+import { PrematureCloseDialogComponent } from './custom-dialogs/premature-close-dialog/premature-close-dialog.component';
import { DepositCertificateDialogComponent } from 'app/shared/deposit-certificate-dialog/deposit-certificate-dialog.component';
/** Custom Button Config. */
@@ -29,6 +30,7 @@ import { AccountsService } from 'app/accounts/accounts.service';
import { Currency } from 'app/shared/models/general.model';
import { SettingsService } from 'app/settings/settings.service';
import { Dates } from 'app/core/utils/dates';
+import { AlertService } from 'app/core/alert/alert.service';
import { AuthenticationService } from '../../../core/authentication/authentication.service';
import { MatTooltip } from '@angular/material/tooltip';
import { NgClass, CurrencyPipe } from '@angular/common';
@@ -73,6 +75,7 @@ import { DormancyAlertPanelComponent } from 'app/shared/dormancy-alert-panel/dor
export class FixedDepositAccountViewComponent implements OnInit {
private route = inject(ActivatedRoute);
private router = inject(Router);
+ private alertService = inject(AlertService);
private fixedDepositsService = inject(FixedDepositsService);
private savingsService = inject(SavingsService);
private accountsService = inject(AccountsService);
@@ -238,7 +241,6 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Undo Activation':
case 'Add Charge':
case 'Withdrawn by Client':
- case 'Premature Close':
case 'Deposit':
case 'Withdrawal':
this.router.navigate([`actions/${name}`], { relativeTo: this.route });
@@ -267,16 +269,21 @@ export class FixedDepositAccountViewComponent implements OnInit {
case 'Payment':
this.openPaymentDialog();
break;
+ case 'Premature Close':
+ this.openPrematureCloseDialog();
+ break;
}
}
/**
- * Opens the Payment action -- unlike Premature Close/Close, works whether the account is Active or
- * Matured, and is the only place a Partial Reinvest can be done.
+ * Opens the Payment action -- only offered once the FD has actually Matured (see
+ * FixedDepositsButtonsConfiguration), and the only place a Partial Reinvest can be done. Also the
+ * exact same dialog the FD Dashboard's Renew action opens (see fd-dashboard.component.ts's
+ * openRenewDialog), just without dashboardRenewal data, so its date picker stays visible.
*/
private openPaymentDialog(): void {
const paymentDialogRef = this.dialog.open(PaymentDialogComponent, {
- width: '640px',
+ width: '720px',
maxWidth: '95vw',
data: {
accountId: this.fixedDepositsAccountData.id,
@@ -293,6 +300,60 @@ export class FixedDepositAccountViewComponent implements OnInit {
});
}
+ /**
+ * The start date of the term currently running -- the last entry in renewalHistory, which is ordered
+ * oldest-first (see RenewalHistoryTabComponent). Falls back to the account's own activation date for
+ * an FD that has never been renewed (or whose renewalHistory hasn't loaded), which is the same date
+ * in that case anyway.
+ */
+ private get currentTermStartDate(): any {
+ const history = this.fixedDepositsAccountData?.renewalHistory;
+ const latestTerm = Array.isArray(history) && history.length > 0 ? history[history.length - 1] : null;
+ return latestTerm?.activatedondate ?? this.fixedDepositsAccountData?.timeline?.activatedOnDate;
+ }
+
+ /**
+ * Opens the Premature Close action -- Active-only, no reinvest option, since the deposit hasn't
+ * matured yet.
+ */
+ private openPrematureCloseDialog(): void {
+ const prematureCloseDialogRef = this.dialog.open(PrematureCloseDialogComponent, {
+ width: '720px',
+ maxWidth: '95vw',
+ data: {
+ accountId: this.fixedDepositsAccountData.id,
+ accountNo: this.fixedDepositsAccountData.fdAccountNumber || this.fixedDepositsAccountData.accountNo,
+ // calculatePrematureAmount only ever returns the final maturityAmount, not how it was reached --
+ // these come from the account's own data (already loaded for this page) so the dialog can show
+ // the breakdown (deposit, contracted rate, penalty, interest earned) behind that one number.
+ depositAmount: this.fixedDepositsAccountData.depositAmount,
+ nominalAnnualInterestRate: this.fixedDepositsAccountData.nominalAnnualInterestRate,
+ preClosurePenalApplicable: this.fixedDepositsAccountData.preClosurePenalApplicable,
+ preClosurePenalInterest: this.fixedDepositsAccountData.preClosurePenalInterest,
+ preClosurePenalInterestOnType: this.fixedDepositsAccountData.preClosurePenalInterestOnType,
+ // Gate which RBI-mandated waiver reasons the dialog offers -- these two are the product's own
+ // facility toggles (Deceased Settlement / No Penalty Nominee-Heir Closure), off by default, so
+ // a product that never enabled them shouldn't offer a reason implying it did.
+ deceasedSettlement: this.fixedDepositsAccountData.deceasedSettlement,
+ noPenaltyNomineeHeirClosure: this.fixedDepositsAccountData.noPenaltyNomineeHeirClosure,
+ // Tenure context (current term's start date, maturity date, current balance) so the dialog can
+ // show how far into THIS term the account is and how much is actually sitting in it today, not
+ // just the penalty-adjusted amount calculatePrematureAmount returns. Deliberately the current
+ // term's own start (see currentTermStartDate), not the account's original activation -- a
+ // renewed FD's interest/tenure runs from the renewal, and showing the original activation date
+ // here would make an account renewed days ago look like it has been running for months.
+ activatedOnDate: this.currentTermStartDate,
+ maturityDate: this.fixedDepositsAccountData.maturityDate,
+ currentBalance: this.fixedDepositsAccountData.summary?.accountBalance
+ }
+ });
+ prematureCloseDialogRef.afterClosed().subscribe((response: any) => {
+ if (response?.confirm) {
+ this.reload();
+ }
+ });
+ }
+
/**
* Deletes Fixed Deposits Account.
*/
@@ -360,7 +421,16 @@ export class FixedDepositAccountViewComponent implements OnInit {
if (response.confirm) {
this.fixedDepositsService
.executeFixedDepositsAccountCommand(this.fixedDepositsAccountData.id, 'postInterest', {})
- .subscribe(() => {
+ .subscribe((commandResponse: any) => {
+ // Parked in the Checker Inbox rather than executed (HTTP 200 with
+ // rollbackTransaction: true), so the reload below shows no new interest posting.
+ if (commandResponse?.rollbackTransaction) {
+ this.alertService.alert({
+ type: 'Success',
+ message:
+ 'Interest posting has been sent for checker approval. No interest is posted until a checker approves it.'
+ });
+ }
this.reload();
});
}
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts
index 3146a730c..9ce40ede6 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/fixed-deposits-buttons.config.ts
@@ -57,11 +57,12 @@ export class FixedDepositsButtonsConfiguration {
];
break;
case 'Matured':
+ // 'Close' (the generic Fineract apply-maturity-instructions action) is deliberately left out
+ // here -- what happens to a matured FD is now handled either automatically by the nightly job
+ // (an account opened with an explicit instruction) or manually via the FD Dashboard's Renew
+ // action (an account left without one, backdated within grace / gap-interest after). Closing
+ // from here would go around both of those paths.
this.buttonsArray = [
- {
- name: 'Close',
- icon: 'fa fa-arrow-right'
- },
{
name: 'Calculate Interest',
icon: 'fa fa-table'
@@ -109,7 +110,18 @@ export class FixedDepositsButtonsConfiguration {
setOptions(status: string) {
switch (status) {
case 'Active':
+ this.optionArray = [
+ {
+ name: 'Post Interest'
+ },
+ {
+ name: 'Add Charge'
+ }
+ ];
+ break;
case 'Matured':
+ // Payment only makes sense once the deposit has actually matured -- an Active FD that needs
+ // closing early already has Premature Close for that.
this.optionArray = [
{
name: 'Post Interest'
diff --git a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
index 3648e2ee1..885983e11 100644
--- a/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
+++ b/src/app/deposits/fixed-deposits/fixed-deposit-account-view/renewal-history-tab/renewal-history-tab.component.html
@@ -41,8 +41,8 @@
) {
+
+ {{ visibleRows.length }} of {{ summary.total }} active FDs
+
+
+
+
+ @if (loading) {
+
+
+
Loading FD maturity list...
+
+ } @else if (visibleRows.length === 0) {
+
+
+
+ @if (!stageFilter && summary.total > 0) {
+ No FDs are matured or maturing within {{ policy.upcomingDays }} days. Click "On Track" to see all active FDs.
+ } @else {
+ No FDs match these filters.
+ }
+
+ @if (crrSlr.status === 'ok') {
+ Within Limits
+ } @else if (crrSlr.status === 'short') {
+ Shortfall
+ } @else {
+ Not computed
+ }
+
diff --git a/src/app/home/dashboard/dashboard.component.scss b/src/app/home/dashboard/dashboard.component.scss
index c0d81a80e..1cde1703d 100644
--- a/src/app/home/dashboard/dashboard.component.scss
+++ b/src/app/home/dashboard/dashboard.component.scss
@@ -444,6 +444,17 @@ $purple: #7c3aed;
font-weight: 600;
}
+/* A figure that could not be computed. Deliberately not green: a tile that cannot read the ledger
+ has not found the bank compliant, and a success tag on an unknown position is a false all-clear. */
+.tag-muted {
+ background: #f3f4f6;
+ color: #4b5563;
+ padding: 2px 6px;
+ border-radius: 10px;
+ font-size: 10px;
+ font-weight: 600;
+}
+
/* ─── Compact Detail Cards ─── */
.dash-card.compact {
padding: 12px 16px;
diff --git a/src/app/home/dashboard/dashboard.component.ts b/src/app/home/dashboard/dashboard.component.ts
index 877f5b3f7..c0605e700 100644
--- a/src/app/home/dashboard/dashboard.component.ts
+++ b/src/app/home/dashboard/dashboard.component.ts
@@ -19,6 +19,7 @@ import { MatTooltipModule } from '@angular/material/tooltip';
import { AsyncPipe, DatePipe } from '@angular/common';
import { FaIconComponent } from '@fortawesome/angular-fontawesome';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { RbiCircularsService } from '../../rbi-circulars/rbi-circulars.service';
/**
* Dashboard component.
*/
@@ -40,9 +41,29 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
export class DashboardComponent implements OnInit {
private router = inject(Router);
private dialog = inject(MatDialog);
+ private rbi = inject(RbiCircularsService);
today = new Date();
+ /**
+ * The CRR/SLR tile, read from the return.
+ *
+ * The amounts and the rates were both fixed text on the template until now, so the tile reported
+ * ₹2,077 Cr at 3% whatever the ledger held and whatever rate Admin was on. A dashboard figure is
+ * read as the bank's position, and one that cannot move is worse than none: nothing on the tile
+ * says it is a placeholder.
+ *
+ * Dashes until the return answers. 'status' drives the compliance tag, which is only claimed
+ * where the return states it.
+ */
+ crrSlr: {
+ crrRate: number | null;
+ slrRate: number | null;
+ crrRequired: string;
+ slrRequired: string;
+ status: 'ok' | 'short' | 'unknown';
+ } = { crrRate: null, slrRate: null, crrRequired: '—', slrRequired: '—', status: 'unknown' };
+
/** Array of all user activities */
userActivity: string[];
/** Array of most recent user activities */
@@ -67,6 +88,45 @@ export class DashboardComponent implements OnInit {
this.recentActivities = this.getRecentActivities();
this.frequentActivities = this.getFrequentActivities();
this.setFilteredActivities();
+ this.loadCrrSlr();
+ }
+
+ /** Fills the CRR/SLR tile from the return, leaving it dashed where the return cannot compute. */
+ private loadCrrSlr(): void {
+ const crore = (v: number | null | undefined): string =>
+ v === null || v === undefined ? '—' : `₹${Math.round(v / 1e7).toLocaleString('en-IN')} Cr`;
+
+ // The statutory Form 1, which is the return this bank files — twice a month, at the 15th and at
+ // the month end. The working report's 45-day lag lands mid-fortnight, on a date the bank never
+ // reported, so a tile built on it shows a position that appears on no filing.
+ this.rbi.getForm1Statutory().subscribe({
+ next: (data: any) => {
+ const latest = (data?.columns?.length ?? 0) - 1;
+ if (latest < 0) {
+ return;
+ }
+ this.crrSlr.crrRate = data?.crrPercentage ?? null;
+ this.crrSlr.slrRate = data?.slrPercentage ?? null;
+ this.crrSlr.crrRequired = crore(data?.partB?.required?.[latest]);
+ this.crrSlr.slrRequired = crore(data?.partC?.required?.[latest]);
+
+ // Compliant only where BOTH ratios are met: the tile carries one tag for the pair, and a
+ // bank short on SLR is not within limits because its cash reserve happens to be ample.
+ const crrHeld = data?.partB?.maintained?.[latest];
+ const crrNeed = data?.partB?.required?.[latest];
+ const slrHeld = data?.partC?.maintained?.[latest];
+ const slrNeed = data?.partC?.required?.[latest];
+ if ([
+ crrHeld,
+ crrNeed,
+ slrHeld,
+ slrNeed
+ ].every((v) => v !== null && v !== undefined)) {
+ this.crrSlr.status = crrHeld >= crrNeed && slrHeld >= slrNeed ? 'ok' : 'short';
+ }
+ },
+ error: () => undefined
+ });
}
/**
diff --git a/src/app/interest-posting-report/interest-posting-report.component.html b/src/app/interest-posting-report/interest-posting-report.component.html
new file mode 100644
index 000000000..b830e7b92
--- /dev/null
+++ b/src/app/interest-posting-report/interest-posting-report.component.html
@@ -0,0 +1,322 @@
+
+
+
+
+
+
Interest Posting Reports
+
+ Each branch gets its own report for each savings product, generated automatically at 12:00 PM on the posting
+ date and open to generating or regenerating at any time. The branch approves it, and its approved interest is
+ posted when the branch closes the day — it cannot close until then. The head office closes last.
+
+ 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)
+
diff --git a/src/app/interest-posting-report/interest-posting-report.component.scss b/src/app/interest-posting-report/interest-posting-report.component.scss
new file mode 100644
index 000000000..a4d744927
--- /dev/null
+++ b/src/app/interest-posting-report/interest-posting-report.component.scss
@@ -0,0 +1,327 @@
+/**
+ * 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/.
+ */
+
+:host {
+ --i-navy: #1a237e;
+ --i-blue: #1d4ed8;
+ --i-blue-l: #eff6ff;
+ --i-green: #15803d;
+ --i-green-l: #dcfce7;
+ --i-amber: #b45309;
+ --i-amber-l: #fef3c7;
+ --i-red: #c0392b;
+ --i-red-l: #fdecea;
+ --i-g100: #f1f5f9;
+ --i-g200: #e2e8f0;
+ --i-g500: #64748b;
+ --i-g900: #0f172a;
+
+ display: block;
+ font-family: 'DM Sans', system-ui, sans-serif;
+ color: var(--i-g900);
+}
+
+.ipr {
+ max-width: 1280px;
+ margin: 0 auto;
+ padding: 16px;
+}
+
+.ipr-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ align-items: flex-end;
+ gap: 12px;
+ margin-bottom: 14px;
+}
+
+.ipr-head-t {
+ margin: 0;
+ font-size: 22px;
+ color: var(--i-navy);
+}
+
+.ipr-head-s {
+ max-width: 720px;
+ margin-top: 4px;
+ font-size: 13px;
+ color: var(--i-g500);
+}
+
+.ipr-head-a {
+ display: flex;
+ align-items: flex-end;
+ gap: 8px;
+}
+
+.ipr-date {
+ display: flex;
+ flex-direction: column;
+ font-size: 11px;
+ color: var(--i-g500);
+
+ input,
+ select {
+ padding: 6px 8px;
+ border: 1px solid var(--i-g200);
+ border-radius: 6px;
+ font: inherit;
+ font-size: 13px;
+ }
+}
+
+.ipr-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ border: 1px solid var(--i-g200);
+ border-radius: 6px;
+ background: #fff;
+ font: inherit;
+ font-size: 13px;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: default;
+ }
+}
+
+.ipr-btn--primary {
+ border-color: var(--i-blue);
+ background: var(--i-blue);
+ color: #fff;
+}
+
+.ipr-btn--slim {
+ padding: 4px 10px;
+ font-size: 12px;
+}
+
+.ipr-alert {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 10px;
+ padding: 10px 14px;
+ border-radius: 8px;
+ font-size: 13px;
+}
+
+.ipr-alert--error {
+ background: var(--i-red-l);
+ color: var(--i-red);
+}
+
+.ipr-alert--ok {
+ background: var(--i-green-l);
+ color: var(--i-green);
+}
+
+.ipr-alert--warn {
+ background: var(--i-amber-l);
+ color: var(--i-amber);
+}
+
+.ipr-card {
+ margin-bottom: 14px;
+ border: 1px solid var(--i-g200);
+ border-radius: 10px;
+ background: #fff;
+ overflow-x: auto;
+}
+
+.ipr-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 13px;
+
+ th,
+ td {
+ padding: 9px 12px;
+ border-bottom: 1px solid var(--i-g100);
+ text-align: left;
+ vertical-align: top;
+ }
+
+ th {
+ background: var(--i-g100);
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--i-g500);
+ }
+
+ tbody tr {
+ cursor: pointer;
+ }
+
+ tbody tr.is-selected {
+ background: var(--i-blue-l);
+ }
+
+ .num {
+ text-align: right;
+ white-space: nowrap;
+ }
+
+ .actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+ cursor: default;
+ }
+
+ tfoot td {
+ background: var(--i-g100);
+ }
+}
+
+.muted {
+ color: var(--i-g500);
+ font-size: 12px;
+}
+
+.ipr-empty {
+ padding: 24px !important;
+ text-align: center !important;
+ color: var(--i-g500);
+}
+
+.ipr-badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 10px;
+ font-size: 11px;
+ font-weight: 600;
+}
+
+.ipr-badge--pending {
+ background: var(--i-amber-l);
+ color: var(--i-amber);
+}
+
+.ipr-badge--approved {
+ background: var(--i-blue-l);
+ color: var(--i-blue);
+}
+
+.ipr-badge--posted {
+ background: var(--i-green-l);
+ color: var(--i-green);
+}
+
+.ipr-badge--muted {
+ background: var(--i-g100);
+ color: var(--i-g500);
+}
+
+.ipr-fail {
+ margin-top: 4px;
+ font-size: 11px;
+ color: var(--i-red);
+}
+
+.ipr-detail-head {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 14px 16px;
+
+ h3 {
+ margin: 0 0 4px;
+ font-size: 16px;
+ color: var(--i-navy);
+ }
+}
+
+.ipr-total {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+
+ strong {
+ font-size: 22px;
+ color: var(--i-navy);
+ }
+}
+
+.ipr-due-body {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ flex: 1;
+}
+
+.ipr-due-row {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+// Approved by the head office on behalf of a branch that did not open that day.
+.ipr-behalf {
+ margin-left: 4px;
+ font-size: 11px;
+ 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
new file mode 100644
index 000000000..c4fd3f1ad
--- /dev/null
+++ b/src/app/interest-posting-report/interest-posting-report.component.ts
@@ -0,0 +1,398 @@
+/**
+ * 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 { SettingsService } from 'app/settings/settings.service';
+import { exportWorkbook } from '../cashier/cashier-export';
+import {
+ InterestPostingDue,
+ InterestPostingReport,
+ InterestPostingReportDetail,
+ InterestPostingReportService,
+ InterestPreviewRow,
+ ReportScope
+} from './interest-posting-report.service';
+
+/**
+ * Savings interest posting reports.
+ *
+ * On a product's posting date the system generates, at noon, the list of what each savings account
+ * will be paid. It is reviewed and approved here; the approved amounts are posted when the last
+ * branch closes the day, and until then no branch can close. Regenerating replaces the report and
+ * needs approving again.
+ */
+@Component({
+ selector: 'mifosx-interest-posting-report',
+ templateUrl: './interest-posting-report.component.html',
+ styleUrls: ['./interest-posting-report.component.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule,
+ FaIconComponent
+ ]
+})
+export class InterestPostingReportComponent implements OnInit {
+ private reportService = inject(InterestPostingReportService);
+ private settingsService = inject(SettingsService);
+
+ postingDate = '';
+ loading = false;
+ busy = false;
+ errorMessage = '';
+ notice = '';
+
+ reports: InterestPostingReport[] = [];
+ selected: InterestPostingReportDetail | null = null;
+ /** 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, undefined, undefined, this.tab).subscribe({
+ next: (reports) => {
+ this.reports = reports ?? [];
+ this.loading = false;
+ const stillThere = this.selected && this.reports.find((r) => r.id === this.selected?.id);
+ if (stillThere) {
+ this.open(stillThere);
+ } else {
+ this.selected = null;
+ }
+ },
+ error: (err) => {
+ this.loading = false;
+ this.errorMessage = this.messageOf(err, 'Could not load the interest posting reports.');
+ }
+ });
+ 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 = '';
+ this.reportService.regenerate(item.productId, item.officeId, item.postingDate).subscribe({
+ next: (res) => {
+ this.busy = false;
+ this.notice = `Report generated for ${item.productName} — ${item.officeName}. Review and approve it.`;
+ this.load();
+ this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
+ },
+ error: (err) => {
+ this.busy = false;
+ this.errorMessage = this.messageOf(err, 'Could not generate the report.');
+ }
+ });
+ }
+
+ open(report: InterestPostingReport): void {
+ this.errorMessage = '';
+ this.reportService.detail(report.id).subscribe({
+ next: (detail) => (this.selected = detail),
+ error: (err) => (this.errorMessage = this.messageOf(err, 'Could not load the report.'))
+ });
+ }
+
+ regenerate(report: InterestPostingReport): void {
+ if (
+ !confirm(
+ `Regenerate the interest posting report for ${report.productName} — ${report.officeName}? It will need approving again.`
+ ) ||
+ !this.confirmOnBehalf(report.officeName, 'regenerate')
+ ) {
+ return;
+ }
+ this.busy = true;
+ this.notice = '';
+ this.errorMessage = '';
+ this.reportService.regenerate(report.productId, report.officeId, report.postingDate).subscribe({
+ next: (res) => {
+ this.busy = false;
+ this.notice = `Report regenerated for ${report.productName}. Review and approve it.`;
+ this.selected = null;
+ this.load();
+ this.reportService.detail(res.reportId).subscribe((detail) => (this.selected = detail));
+ },
+ error: (err) => {
+ this.busy = false;
+ this.errorMessage = this.messageOf(err, 'Could not regenerate the report.');
+ }
+ });
+ }
+
+ approve(report: InterestPostingReport): void {
+ if (
+ !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;
+ }
+ this.busy = true;
+ this.notice = '';
+ this.errorMessage = '';
+ this.reportService.approve(report.id).subscribe({
+ next: () => {
+ this.busy = false;
+ this.notice = `Approved. Interest for ${report.productName} — ${report.officeName} will be posted when the branch closes the day.`;
+ this.load();
+ },
+ error: (err) => {
+ this.busy = false;
+ this.errorMessage = this.messageOf(err, 'Could not approve the report.');
+ }
+ });
+ }
+
+ canRegenerate(report: InterestPostingReport): boolean {
+ return report.canAct && (report.status === 'GENERATED' || report.status === 'APPROVED');
+ }
+
+ canApprove(report: InterestPostingReport): boolean {
+ return report.canAct && report.status === 'GENERATED';
+ }
+
+ statusClass(status: string): string {
+ return (
+ {
+ GENERATED: 'ipr-badge--pending',
+ APPROVED: 'ipr-badge--approved',
+ POSTED: 'ipr-badge--posted',
+ SUPERSEDED: 'ipr-badge--muted'
+ }[status] ?? 'ipr-badge--muted'
+ );
+ }
+
+ /** Pending reports that block this user's own day close; another branch's pending reports block only that branch. */
+ get pendingCount(): number {
+ 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 {
+ const businessDate = this.settingsService.businessDate as any;
+ const parsed = businessDate ? new Date(businessDate) : new Date();
+ if (isNaN(parsed.getTime())) {
+ return new Date().toISOString().slice(0, 10);
+ }
+ const month = String(parsed.getMonth() + 1).padStart(2, '0');
+ const day = String(parsed.getDate()).padStart(2, '0');
+ return `${parsed.getFullYear()}-${month}-${day}`;
+ }
+
+ private messageOf(err: any, fallback: string): string {
+ return (
+ err?.error?.errors?.[0]?.defaultUserMessage ?? err?.error?.defaultUserMessage ?? err?.error?.message ?? fallback
+ );
+ }
+}
diff --git a/src/app/interest-posting-report/interest-posting-report.service.ts b/src/app/interest-posting-report/interest-posting-report.service.ts
new file mode 100644
index 000000000..7d2466e49
--- /dev/null
+++ b/src/app/interest-posting-report/interest-posting-report.service.ts
@@ -0,0 +1,180 @@
+/**
+ * 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 { Injectable, inject } from '@angular/core';
+import { HttpClient, HttpParams } from '@angular/common/http';
+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;
+ productName: string;
+ officeId: number;
+ officeName: string;
+ postingDate: string;
+ status: InterestPostingReportStatus;
+ totalInterest: number;
+ accountCount: number;
+ generationMode: 'AUTO' | 'MANUAL';
+ generatedAt: string;
+ generatedBy: string | null;
+ approvedAt: string | null;
+ approvedBy: string | null;
+ approvedOnBehalf: boolean;
+ postedAt: string | null;
+ 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 {
+ savingsAccountId: number;
+ accountNo: string;
+ clientName: string;
+ interestAmount: number;
+ balanceBefore: number;
+ balanceAfter: number;
+ savingsTransactionId: number | null;
+}
+
+export interface InterestPostingReportDetail extends InterestPostingReport {
+ payableGlCode: string | null;
+ payableGlName: string | null;
+ paidGlCode: string | null;
+ paidGlName: string | null;
+ expenseGlCode: string | null;
+ expenseGlName: string | null;
+ lines: InterestPostingReportLine[];
+}
+
+export interface InterestPostingDue {
+ productId: number;
+ productName: string;
+ officeId: number;
+ officeName: string;
+ postingDate: string;
+ canAct: boolean;
+ blockedReason: string | null;
+}
+
+export interface InterestPostingSummaryRow {
+ id: number;
+ postingDate: string;
+ officeId: number;
+ officeName: string;
+ productId: number;
+ productName: string;
+ accountCount: number;
+ totalInterest: number;
+ status: InterestPostingReportStatus;
+ approvedBy: string | null;
+ approvedOnBehalf: boolean;
+ approvedAt: string | null;
+ 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.
+ */
+@Injectable({ providedIn: 'root' })
+export class InterestPostingReportService {
+ private http = inject(HttpClient);
+ private readonly base = '/savingsinterestpostingreports';
+
+ /** 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));
+ return this.http.get(this.base, { params });
+ }
+
+ /** Branch and product pairs with interest due by the date and no report yet, that this user may generate. */
+ 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}`);
+ }
+
+ regenerate(
+ productId: number,
+ officeId: number,
+ postingDate: string
+ ): Observable<{ reportId: number; status: string }> {
+ return this.http.post<{ reportId: number; status: string }>(
+ this.base,
+ { productId, officeId, postingDate },
+ { params: new HttpParams().set('command', 'regenerate') }
+ );
+ }
+
+ approve(reportId: number): Observable<{ reportId: number; status: string }> {
+ return this.http.post<{ reportId: number; status: string }>(
+ `${this.base}/${reportId}`,
+ {},
+ { params: new HttpParams().set('command', 'approve') }
+ );
+ }
+
+ /** 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();
+ if (fromDate) params = params.set('fromDate', fromDate);
+ if (toDate) params = params.set('toDate', toDate);
+ if (productId) params = params.set('productId', String(productId));
+ return this.http.get(`${this.base}/summary`, { params });
+ }
+}
diff --git a/src/app/interest-posting-summary/interest-posting-summary.component.html b/src/app/interest-posting-summary/interest-posting-summary.component.html
new file mode 100644
index 000000000..8ecca4e98
--- /dev/null
+++ b/src/app/interest-posting-summary/interest-posting-summary.component.html
@@ -0,0 +1,172 @@
+
+
+
+
+
+
Interest Posting Summary
+
+ Savings interest across every branch: for each posting date, branch and product, how many customers were paid
+ and how much. Each branch approves and posts its own; this is the head office's view.
+
+
+
+
+
+
+ Reload
+
+ Export Excel
+
+
+
+
+ @if (errorMessage) {
+
+
+
{{ errorMessage }}
+
+ }
+ @if (pendingCount > 0) {
+
+
+
{{ pendingCount }} branch report(s) in this period are not posted yet.
- OD Account Closed with Zero Balance in Day Closing
+ End OD Once Fully Repaid (One-time OD only)
- Automatically closes OD accounts when balance reaches zero during day-end closing.
+ At day-end, withdraws the OD sanction when the debit balance is cleared to zero. Only the OD ends, not
+ the account. Leave off for a regular OD: a customer who uses it and repays the same day would lose it.
- Average Balance Calculation in Period
-
- 6M
- 1Y
- 3Y
-
-
-
-
- Valid for Period
-
- 3M
- 1Y
- 3Y
-
-
-
- OD Limit %
+ Maximum OD Limit (₹)
+ Highest per account
+ Enter the maximum OD limit
- Interest on OD %
-
+ Minimum OD Limit (₹)
+
+ Lowest per account
+ @if (CurrentProductSettingsForm.get('odMinLimit')?.hasError('exceedsMax')) {
+ Cannot exceed the maximum OD limit
+ } @else {
+ Cannot be negative
+ }
+
+
+ OD Security Type
+
+ @for (option of odSecurityTypeOptions; track option.value) {
+ {{ option.label }}
+ }
+
+
Off: a withdrawal that would cross the limit is refused.
+ } @else {
+
+
+ Maximum Excess (% of limit)
+
+ Beyond this, refused
+ Enter above 0% and up to 25%
+
+
+
+ Penal Charge on Excess (% p.a.)
+
+ Only on the excess amount
+ Enter above 0% and up to 50%
+
+
+ @if (excessExample) {
+
{{ excessExample }}
+ }
+ }
+
+
+
+ Each account is sanctioned its own limit within this range, valid for the period above. The OD interest
+ rate is set on the Terms tab.
+
}
diff --git a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
index de2e04d2d..f68d32c94 100644
--- a/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
+++ b/src/app/products/current-products/current-product-stepper/current-product-settings-step/current-product-settings-step.component.ts
@@ -7,7 +7,7 @@
*/
import { Component, OnInit, Input, inject } from '@angular/core';
-import { UntypedFormGroup, UntypedFormBuilder, Validators } from '@angular/forms';
+import { AbstractControl, UntypedFormGroup, UntypedFormBuilder, ValidationErrors, Validators } from '@angular/forms';
import { MatCheckbox } from '@angular/material/checkbox';
import { MatRadioGroup, MatRadioButton } from '@angular/material/radio';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
@@ -40,6 +40,21 @@ export class CurrentProductSettingsStepComponent implements OnInit {
{ id: 'YEARS', value: 'value.years' }
];
+ /** How long an OD sanction on this product runs before it must be renewed. */
+ odValidityOptions = [
+ { value: '3M', label: '3 Months' },
+ { value: '6M', label: '6 Months' },
+ { value: '1Y', label: '1 Year' },
+ { value: '2Y', label: '2 Years' },
+ { value: '3Y', label: '3 Years' }
+ ];
+
+ odSecurityTypeOptions = [
+ { value: 'CLEAN', label: 'Clean', hint: 'No security needed' },
+ { value: 'SECURED', label: 'Secured', hint: 'Against FD lien, property, etc.' },
+ { value: 'BOTH', label: 'Both', hint: 'Decided at each sanction' }
+ ];
+
constructor() {
this.createCurrentProductSettingsForm();
this.setConditionalControls();
@@ -72,14 +87,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToEscheat: isEditing
? this.CurrentProductsTemplate.daysToEscheat || DORMANCY_DEFAULTS.daysToEscheat
: DORMANCY_DEFAULTS.daysToEscheat,
- // Overdraft fields
+ // Overdraft scheme (the OD interest rate lives on the Terms tab)
allowOverdraft: isEditing ? (this.CurrentProductsTemplate.allowOverdraft ?? false) : false,
overdraftLimit: isEditing ? this.CurrentProductsTemplate.overdraftLimit : '',
- nominalAnnualInterestRateOverdraft: isEditing
- ? this.CurrentProductsTemplate.nominalAnnualInterestRateOverdraft
- : '',
- avgBalanceCalcPeriod: isEditing ? this.CurrentProductsTemplate.avgBalanceCalcPeriod || '1Y' : '1Y',
- validForPeriod: isEditing ? this.CurrentProductsTemplate.validForPeriod || '1Y' : '1Y',
+ odMinLimit: isEditing ? this.CurrentProductsTemplate.odMinLimit : '',
+ odSecurityType: isEditing ? this.CurrentProductsTemplate.odSecurityType || 'CLEAN' : 'CLEAN',
+ validForPeriod: this.validityOrDefault(isEditing ? this.CurrentProductsTemplate.validForPeriod : null),
+ odExcessAllowed: isEditing ? !!this.CurrentProductsTemplate.odExcessAllowed : false,
+ odExcessLimitPercent: isEditing ? this.CurrentProductsTemplate.odExcessLimitPercent : '',
+ odPenalRate: isEditing ? this.CurrentProductsTemplate.odPenalRate : '',
odAccountClosedZeroBalanceInDayClosing: isEditing
? (this.CurrentProductsTemplate.odAccountClosedZeroBalanceInDayClosing ?? false)
: false
@@ -123,23 +139,119 @@ export class CurrentProductSettingsStepComponent implements OnInit {
daysToInactive: [DORMANCY_DEFAULTS.daysToInactive],
daysToDormancy: [DORMANCY_DEFAULTS.daysToDormancy],
daysToEscheat: [DORMANCY_DEFAULTS.daysToEscheat],
- // Overdraft (OD) Facility
+ // Overdraft (OD) Facility: the scheme an account's OD sanction is held to
allowOverdraft: [false],
- overdraftLimit: [
- '',
- [Validators.min(0)]
- ],
- nominalAnnualInterestRateOverdraft: [
- '',
- [Validators.min(0)]
- ],
- avgBalanceCalcPeriod: ['1Y'],
+ overdraftLimit: [''],
+ odMinLimit: [''],
+ odSecurityType: ['CLEAN'],
validForPeriod: ['1Y'],
+ // Excess drawing past the sanctioned limit: off means it is refused
+ odExcessAllowed: [false],
+ odExcessLimitPercent: [''],
+ odPenalRate: [''],
odAccountClosedZeroBalanceInDayClosing: [false]
});
}
+ get selectedSecurityTypeHint(): string {
+ const selected = this.CurrentProductSettingsForm.get('odSecurityType')?.value;
+ return this.odSecurityTypeOptions.find((option) => option.value === selected)?.hint ?? '';
+ }
+
+ /** A worked example on the maximum limit, so the percentages read as rupees. */
+ get excessExample(): string {
+ const value = this.CurrentProductSettingsForm.value;
+ const limit = Number(value.overdraftLimit);
+ const percent = Number(value.odExcessLimitPercent);
+ const penal = Number(value.odPenalRate);
+ if (!limit || !percent) return '';
+ const excess = (limit * percent) / 100;
+ const inr = (n: number) => '₹' + n.toLocaleString('en-IN', { maximumFractionDigits: 2 });
+ let text = `On a ${inr(limit)} limit, up to ${inr(excess)} more may be drawn.`;
+ if (penal) {
+ text += ` If ${inr(excess)} stays over the limit for a day, the penal charge is ${inr((excess * penal) / 100 / 365)}.`;
+ }
+ return text;
+ }
+
+ private validityOrDefault(value: string | null): string {
+ return this.odValidityOptions.some((option) => option.value === value) ? value : '1Y';
+ }
+
+ /** The minimum sanction may not exceed the maximum. */
+ private odMinWithinMax = (control: AbstractControl): ValidationErrors | null => {
+ const max = Number(this.CurrentProductSettingsForm?.get('overdraftLimit')?.value);
+ const min = Number(control.value);
+ if (control.value === '' || control.value === null || !max) return null;
+ return min > max ? { exceedsMax: true } : null;
+ };
+
+ private applyOverdraftValidators(allowed: boolean) {
+ const max = this.CurrentProductSettingsForm.get('overdraftLimit');
+ const min = this.CurrentProductSettingsForm.get('odMinLimit');
+ const security = this.CurrentProductSettingsForm.get('odSecurityType');
+ const validity = this.CurrentProductSettingsForm.get('validForPeriod');
+ if (allowed) {
+ max.setValidators([
+ Validators.required,
+ Validators.min(1)
+ ]);
+ min.setValidators([
+ Validators.min(0),
+ this.odMinWithinMax
+ ]);
+ security.setValidators([Validators.required]);
+ validity.setValidators([Validators.required]);
+ } else {
+ [
+ max,
+ min,
+ security,
+ validity
+ ].forEach((control) => control.clearValidators());
+ }
+ [
+ max,
+ min,
+ security,
+ validity
+ ].forEach((control) => control.updateValueAndValidity({ emitEvent: false }));
+ this.applyExcessValidators();
+ }
+
+ /** The excess cap and penal rate are required only while both OD and excess drawing are on. */
+ private applyExcessValidators() {
+ const form = this.CurrentProductSettingsForm;
+ const required = !!form.get('allowOverdraft').value && !!form.get('odExcessAllowed').value;
+ const percent = form.get('odExcessLimitPercent');
+ const penal = form.get('odPenalRate');
+ percent.setValidators(
+ required ? [
+ Validators.required,
+ Validators.min(0.01),
+ Validators.max(25)
+ ] : []
+ );
+ penal.setValidators(
+ required ? [
+ Validators.required,
+ Validators.min(0.01),
+ Validators.max(50)
+ ] : []
+ );
+ percent.updateValueAndValidity({ emitEvent: false });
+ penal.updateValueAndValidity({ emitEvent: false });
+ }
+
setConditionalControls() {
+ this.CurrentProductSettingsForm.get('allowOverdraft').valueChanges.subscribe((allowed: boolean) =>
+ this.applyOverdraftValidators(!!allowed)
+ );
+ this.CurrentProductSettingsForm.get('odExcessAllowed').valueChanges.subscribe(() => this.applyExcessValidators());
+ this.CurrentProductSettingsForm.get('overdraftLimit').valueChanges.subscribe(() =>
+ this.CurrentProductSettingsForm.get('odMinLimit').updateValueAndValidity({ emitEvent: false })
+ );
+
this.CurrentProductSettingsForm.get('enableLockinPeriod').valueChanges.subscribe((enabled: boolean) => {
const freq = this.CurrentProductSettingsForm.get('lockinPeriodFrequency');
const type = this.CurrentProductSettingsForm.get('lockinPeriodFrequencyType');
@@ -168,10 +280,15 @@ export class CurrentProductSettingsStepComponent implements OnInit {
if (!formValue.allowOverdraft) {
delete formValue.overdraftLimit;
- delete formValue.nominalAnnualInterestRateOverdraft;
- delete formValue.avgBalanceCalcPeriod;
+ delete formValue.odMinLimit;
+ delete formValue.odSecurityType;
delete formValue.validForPeriod;
delete formValue.odAccountClosedZeroBalanceInDayClosing;
+ delete formValue.odExcessAllowed;
+ }
+ if (!formValue.allowOverdraft || !formValue.odExcessAllowed) {
+ delete formValue.odExcessLimitPercent;
+ delete formValue.odPenalRate;
}
return formValue;
diff --git a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
index f2350d95a..a7d6adee9 100644
--- a/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
+++ b/src/app/products/current-products/current-product-stepper/current-product-terms-step/current-product-terms-step.component.html
@@ -13,20 +13,78 @@
Interest On OD
- Frequency Fix Day
- {{ currentProduct.frequencyFixDay }}
-
- }
- @if (currentProduct.lastInterestDate) {
-
- Last Interest Date
- {{ currentProduct.lastInterestDate | dateFormat }}
+ Days in Year
+ {{ currentProduct.interestCalculationDaysInYearType.id }} Days
diff --git a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts
index 40c634e85..3b9f19df2 100644
--- a/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts
+++ b/src/app/products/current-products/view-current-product/current-product-general-tab/current-product-general-tab.component.ts
@@ -22,7 +22,6 @@ import {
MatRowDef,
MatRow
} from '@angular/material/table';
-import { ViewSavingsAccountingDetailsComponent } from '../../../../shared/accounting/view-savings-accounting-details/view-savings-accounting-details.component';
import { DateFormatPipe } from '../../../../pipes/date-format.pipe';
import { FormatNumberPipe } from '../../../../pipes/format-number.pipe';
import { YesnoPipe } from '../../../../pipes/yesno.pipe';
@@ -47,7 +46,6 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
MatHeaderRow,
MatRowDef,
MatRow,
- ViewSavingsAccountingDetailsComponent,
DateFormatPipe,
FormatNumberPipe,
YesnoPipe
@@ -102,4 +100,20 @@ export class CurrentProductGeneralTabComponent {
isCashOrAccrualAccounting(): boolean {
return this.accounting.isCashOrAccrualAccounting(this.currentProduct.accountingRule);
}
+
+ /** The GLs a current product maps, by the names the Accounting tab gives them. */
+ get currentAccountingRows(): Array<{ label: string; account: any }> {
+ const m = this.currentProduct?.accountingMappings || {};
+ const rows = [
+ { label: 'Current Control (Liability)', account: m.savingsControlAccount },
+ { label: 'Overdraft Portfolio Control (Asset)', account: m.overdraftPortfolioControl },
+ { label: 'Income From Fees', account: m.incomeFromFeeAccount },
+ { label: 'Income From Penalties', account: m.incomeFromPenaltyAccount },
+ { label: 'Income From Interest', account: m.incomeFromInterest }
+ ];
+ if (this.currentProduct?.isDormancyTrackingActive) {
+ rows.push({ label: 'Escheat Liability', account: m.escheatLiabilityAccount });
+ }
+ return rows;
+ }
}
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts
index 921f9773e..e1004052a 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-accounting-step/fixed-deposit-product-accounting-step.component.ts
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
+const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
+const FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Fixed Deposit Interest Paid';
+const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
+
type AccountingRowConfig = {
key: string;
label: string;
@@ -128,6 +132,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
+ savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -158,6 +163,20 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
return '';
}
+ private extractNamedGlMapping(
+ otherGlMappings: any[],
+ accountName: string,
+ controlName: string,
+ options: GLAccount[]
+ ): any[] {
+ const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
+ if (match) {
+ const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
+ this.fixedDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
+ }
+ return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
+ }
+
private patchAccountingValues(mappings: any) {
this.fixedDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: this.extractAccountId(
@@ -200,7 +219,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
- this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
+ const fromExpenses = this.extractNamedGlMapping(
+ this.extractNamedGlMapping(
+ glMappings?.expenses || [],
+ FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.expenseAccountOptions
+ ),
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.expenseAccountOptions
+ );
+ this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -266,10 +296,26 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
+ const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
+ const glAccountId = this.fixedDepositProductAccountingForm.get(controlName)?.value;
+ if (!glAccountId) {
+ return [];
+ }
+ const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
+ return glCode ? [{ accountName, glCode }] : [];
+ };
+
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
- expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
+ expenses: [
+ ...namedAsOtherGlMapping(
+ 'savingsInterestPaidAccountId',
+ FIXED_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
+ this.expenseAccountOptions
+ ),
+ ...map('expensesOtherGlMappings', this.expenseAccountOptions)
+ ],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -308,6 +354,11 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
setRequired('incomeFromPenaltyAccountId', cashOrAccrual);
setRequired('feesReceivableAccountId', accrualOnly);
setRequired('penaltiesReceivableAccountId', accrualOnly);
+ // Required only on accrual, where there is no posting without it. On cash it stays optional but
+ // is offered, because interest posting routes through it when the product names one (see
+ // AccountingProcessorHelper.createInterestPostingJournalEntriesForSavings): the payable carries
+ // interest declared but not yet credited, and a product that names none collapses straight to
+ // Savings Control instead.
setRequired('interestPayableAccountId', accrualOnly);
setRequired('escheatLiabilityId', cashOrAccrual && this.isDormancyTrackingEnabled());
@@ -326,8 +377,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsReferenceAccountId',
- label: 'Savings Reference Account',
- inputLabel: 'Savings reference account',
+ label: 'Fixed Deposit Reference Account',
+ inputLabel: 'Fixed deposit reference account',
controlName: 'savingsReferenceAccountId',
options: this.assetAccountOptions,
required: true
@@ -360,8 +411,8 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'savingsControlAccountId',
- label: 'Savings Control Account',
- inputLabel: 'Savings control account',
+ label: 'Fixed Deposit Control Account',
+ inputLabel: 'Fixed deposit control account',
controlName: 'savingsControlAccountId',
options: this.liabilityAccountOptions,
required: true
@@ -375,12 +426,16 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
required: true
},
{
+ // Offered on cash accounting too, not only accrual: interest posting routes through
+ // this account whichever basis the product uses, so that interest declared but not yet
+ // credited to the member reads as the bank's own liability rather than appearing only
+ // when it is paid. Optional on cash - a product naming none posts straight to Savings
+ // Control as before.
key: 'interestPayableAccountId',
label: 'Interest Payable Account',
inputLabel: 'Interest payable account',
controlName: 'interestPayableAccountId',
- options: this.liabilityAccountOptions,
- accrualOnly: true
+ options: this.liabilityAccountOptions
},
{
key: 'escheatLiabilityId',
@@ -402,11 +457,18 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
rows: [
{
key: 'interestOnSavingsAccountId',
- label: 'Interest On Savings Account',
- inputLabel: 'Interest on savings account',
+ label: 'Interest On Fixed Deposits',
+ inputLabel: 'Interest on fixed deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
+ },
+ {
+ key: 'savingsInterestPaidAccountId',
+ label: 'Fixed Deposit Interest Paid',
+ inputLabel: 'Fixed deposit interest paid',
+ controlName: 'savingsInterestPaidAccountId',
+ options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -447,6 +509,7 @@ export class FixedDepositProductAccountingStepComponent implements OnInit, OnCha
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
+ savingsInterestPaidAccountId,
...accounting
} = this.fixedDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html
index e92b052fd..8a6431b90 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-preview-step/fixed-deposit-product-preview-step.component.html
@@ -481,6 +481,7 @@
}
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html
index 239c749bd..504d06752 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.html
@@ -34,6 +34,35 @@
+
+
+
+
+ Renewal
+
+
+
+ ℹ️
+
+ Leave blank for an FD on this product to keep renewing instantly, the same night it matures, as before. Set a
+ number of days and a matured "auto renew" FD instead waits on the FD Dashboard for that many days for staff to
+ renew it manually -- backdated to the original maturity date within that window, or from the day it is
+ actually renewed (with gap-period interest at the linked Savings account's rate) once the window has passed.
+
+
+
+
+ Renewal Grace Period
+
+ Days
+ @if (fixedDepositProductTermsForm.get('renewalGracePeriodDays').hasError('min')) {
+ Renewal Grace Period must be zero or greater
+ }
+
+
+
+
+
diff --git a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
index 11061126a..d1abb4ad6 100644
--- a/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
+++ b/src/app/products/fixed-deposit-products/fixed-deposit-product-stepper/fixed-deposit-product-terms-step/fixed-deposit-product-terms-step.component.ts
@@ -85,12 +85,21 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
: (this.interestCalculationTypeOptionData?.[0]?.id ?? null);
this.fixedDepositProductTermsForm.patchValue({
- minDepositTerm: this.fixedDepositProductsTemplate.minDepositTerm ?? 1,
- minDepositTermTypeId: this.fixedDepositProductsTemplate.minDepositTermType?.id ?? defaultPeriodTypeId,
+ // Hidden, unedited-by-anyone fields. On EDIT, left as the product's own saved value with no
+ // forced fallback (like their max-/in-multiples-of- siblings already correctly are), or a
+ // checker's "Proposed changes" review showed these as changed on every unrelated edit, never
+ // anything a maker touched. On CREATE there is no saved value yet, and the backend requires
+ // minDepositTerm -- default it (and its unit) here same as before, since the field is hidden
+ // and no one gets a chance to fill it in themselves.
+ minDepositTerm: isEditing ? (this.fixedDepositProductsTemplate.minDepositTerm ?? '') : 1,
+ minDepositTermTypeId: isEditing
+ ? (this.fixedDepositProductsTemplate.minDepositTermType?.id ?? '')
+ : defaultPeriodTypeId,
maxDepositTerm: this.fixedDepositProductsTemplate.maxDepositTerm ?? '',
maxDepositTermTypeId: this.fixedDepositProductsTemplate.maxDepositTermType?.id ?? '',
inMultiplesOfDepositTerm: this.fixedDepositProductsTemplate.inMultiplesOfDepositTerm ?? '',
inMultiplesOfDepositTermTypeId: this.fixedDepositProductsTemplate.inMultiplesOfDepositTermType?.id ?? '',
+ renewalGracePeriodDays: this.fixedDepositProductsTemplate.renewalGracePeriodDays ?? '',
minDepositAmount: this.fixedDepositProductsTemplate.minDepositAmount,
maxDepositAmount: this.fixedDepositProductsTemplate.maxDepositAmount,
nominalAnnualInterestRate: this.fixedDepositProductsTemplate.nominalAnnualInterestRate ?? 0,
@@ -171,6 +180,13 @@ export class FixedDepositProductTermsStepComponent implements OnInit, OnChanges
maxDepositTermTypeId: [''],
inMultiplesOfDepositTerm: [''],
inMultiplesOfDepositTermTypeId: [''],
+ // Days after maturity a depositor can still walk in and have their FD renewed backdated to the
+ // original maturity date, at the currently active rate -- blank/0 keeps today's instant,
+ // same-night auto-renewal (see FD Dashboard for the manual renewal this enables).
+ renewalGracePeriodDays: [
+ '',
+ Validators.min(0)
+ ],
minDepositAmount: [
'',
Validators.min(0)
diff --git a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
index cb3324ea9..458eece5b 100644
--- a/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
+++ b/src/app/products/fixed-deposit-products/view-fixed-deposit-product/fixed-deposit-general-tab/fixed-deposit-general-tab.component.html
@@ -607,6 +607,7 @@
}
diff --git a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts
index 3ceaa248a..6d3332921 100644
--- a/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts
+++ b/src/app/products/recurring-deposit-products/recurring-deposit-product-stepper/recurring-deposit-product-accounting-step/recurring-deposit-product-accounting-step.component.ts
@@ -24,6 +24,10 @@ import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
type AccountingSectionKey = 'assets' | 'liabilities' | 'expenses' | 'income';
+const SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME = 'Savings Interest Payable';
+const RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME = 'Recurring Deposit Interest Paid';
+const SAVINGS_INTEREST_PAID_ACCOUNT_NAME = 'Savings Interest Paid';
+
type AccountingRowConfig = {
key: string;
label: string;
@@ -122,6 +126,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
feesReceivableAccountId: [''],
penaltiesReceivableAccountId: [''],
interestPayableAccountId: [''],
+ savingsInterestPaidAccountId: [''],
escheatLiabilityId: [''],
assetsOtherGlMappings: this.formBuilder.array([]),
liabilitiesOtherGlMappings: this.formBuilder.array([]),
@@ -136,6 +141,20 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
});
}
+ private extractNamedGlMapping(
+ otherGlMappings: any[],
+ accountName: string,
+ controlName: string,
+ options: GLAccount[]
+ ): any[] {
+ const match = (otherGlMappings || []).find((mapping) => mapping?.accountName === accountName);
+ if (match) {
+ const glAccountId = match.glAccountId || options.find((option) => option.glCode === match.glCode)?.id || '';
+ this.recurringDepositProductAccountingForm.get(controlName)?.patchValue(glAccountId);
+ }
+ return (otherGlMappings || []).filter((mapping) => mapping?.accountName !== accountName);
+ }
+
private patchAccountingValues(mappings: any) {
this.recurringDepositProductAccountingForm.patchValue({
savingsReferenceAccountId: mappings.savingsReferenceAccount?.id || '',
@@ -158,7 +177,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
glMappings?.liabilities || [],
this.liabilityAccountOptions
);
- this.setOtherGlMappings('expensesOtherGlMappings', glMappings?.expenses || [], this.expenseAccountOptions);
+ const fromExpenses = this.extractNamedGlMapping(
+ this.extractNamedGlMapping(
+ glMappings?.expenses || [],
+ RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.expenseAccountOptions
+ ),
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.expenseAccountOptions
+ );
+ this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -224,10 +254,26 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
}))
.filter((item: { accountName: string; glCode: string }) => !!item.accountName || !!item.glCode);
+ const namedAsOtherGlMapping = (controlName: string, accountName: string, options: GLAccount[]) => {
+ const glAccountId = this.recurringDepositProductAccountingForm.get(controlName)?.value;
+ if (!glAccountId) {
+ return [];
+ }
+ const glCode = options.find((o) => o.id === glAccountId)?.glCode || '';
+ return glCode ? [{ accountName, glCode }] : [];
+ };
+
const otherGlMappings = {
assets: map('assetsOtherGlMappings', this.assetAccountOptions),
liabilities: map('liabilitiesOtherGlMappings', this.liabilityAccountOptions),
- expenses: map('expensesOtherGlMappings', this.expenseAccountOptions),
+ expenses: [
+ ...namedAsOtherGlMapping(
+ 'savingsInterestPaidAccountId',
+ RECURRING_DEPOSIT_INTEREST_PAID_ACCOUNT_NAME,
+ this.expenseAccountOptions
+ ),
+ ...map('expensesOtherGlMappings', this.expenseAccountOptions)
+ ],
income: map('incomeOtherGlMappings', this.incomeAccountOptions)
};
return Object.values(otherGlMappings).some((v) => v.length) ? otherGlMappings : null;
@@ -360,11 +406,18 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
rows: [
{
key: 'interestOnSavingsAccountId',
- label: 'Interest On Savings Account',
- inputLabel: 'Interest on savings account',
+ label: 'Interest On Recurring Deposits',
+ inputLabel: 'Interest on recurring deposits',
controlName: 'interestOnSavingsAccountId',
options: this.expenseAccountOptions,
required: true
+ },
+ {
+ key: 'savingsInterestPaidAccountId',
+ label: 'Recurring Deposit Interest Paid',
+ inputLabel: 'Recurring deposit interest paid',
+ controlName: 'savingsInterestPaidAccountId',
+ options: this.expenseAccountOptions
}
],
otherMappingsControlName: 'expensesOtherGlMappings',
@@ -405,6 +458,7 @@ export class RecurringDepositProductAccountingStepComponent implements OnInit {
liabilitiesOtherGlMappings,
expensesOtherGlMappings,
incomeOtherGlMappings,
+ savingsInterestPaidAccountId,
...accounting
} = this.recurringDepositProductAccountingForm.value;
const otherGlMappings = this.getOtherGlMappings();
diff --git a/src/app/products/saving-products/create-saving-product/create-saving-product.component.html b/src/app/products/saving-products/create-saving-product/create-saving-product.component.html
index 867619fa8..f60a295f3 100644
--- a/src/app/products/saving-products/create-saving-product/create-saving-product.component.html
+++ b/src/app/products/saving-products/create-saving-product/create-saving-product.component.html
@@ -144,7 +144,7 @@
}
@if (savingProductFormValid && selectedTabIndex === totalTabs - 1) {
-
+
{{ 'labels.buttons.Submit' | translate }}
diff --git a/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts b/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts
index 942b0f195..7207b8d78 100644
--- a/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts
+++ b/src/app/products/saving-products/create-saving-product/create-saving-product.component.ts
@@ -70,6 +70,10 @@ export class CreateSavingProductComponent {
savingProductsTemplate: any;
accountingRuleData: string[] = [];
selectedTabIndex = 0;
+ /** Guards against repeat clicks on Submit: the product name is unique in the database, so a
+ * second request for the same form fails with a duplicate key error even though the first one
+ * created the product. */
+ submitting = false;
/**
* @param {ActivatedRoute} route Activated Route.
@@ -162,8 +166,11 @@ export class CreateSavingProductComponent {
}
submit() {
+ if (this.submitting) {
+ return;
+ }
+ this.submitting = true;
const savingProduct = this.prepareSavingProductPayload();
- console.log('Saving product submit payload:', savingProduct);
this.productsService.createSavingProduct(savingProduct).subscribe({
next: (response: any) => {
if (response?.rollbackTransaction) {
@@ -181,6 +188,11 @@ export class CreateSavingProductComponent {
{ relativeTo: this.route }
);
}
+ },
+ error: () => {
+ // the error itself is reported by the global error handler; just let the user correct the
+ // form and try again
+ this.submitting = false;
}
});
}
diff --git a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
index 9f45429ff..a46c736b8 100644
--- a/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
+++ b/src/app/products/saving-products/saving-product-stepper/saving-product-accounting-step/saving-product-accounting-step.component.ts
@@ -88,6 +88,8 @@ export class SavingProductAccountingStepComponent implements OnInit {
liabilityAccountOptions: GLAccount[] = [];
expenseAccountOptions: GLAccount[] = [];
incomeAccountOptions: GLAccount[] = [];
+ /** Savings Interest Paid sits under Expenses and offers expense GLs; it nets to nil at each posting. */
+ paidAccountOptions: GLAccount[] = [];
assetAccountOptions: GLAccount[] = [];
paymentTypeOptions: any[] = [];
/** A payment-type Fund Source override can legitimately be a suspense/clearing Liability account
@@ -114,6 +116,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
this.liabilityAccountOptions = accountingMappingOptions.liabilityAccountOptions || [];
this.expenseAccountOptions = accountingMappingOptions.expenseAccountOptions || [];
this.incomeAccountOptions = accountingMappingOptions.incomeAccountOptions || [];
+ this.paidAccountOptions = this.expenseAccountOptions;
this.paymentTypeOptions = this.savingProductsTemplate?.paymentTypeOptions || [];
this.fundSourceAccountOptions = this.assetAccountOptions.concat(this.liabilityAccountOptions);
this.buildAccountingSectionConfigs();
@@ -201,26 +204,30 @@ export class SavingProductAccountingStepComponent implements OnInit {
// Backend serializes as 'otherGlMappingsMap'; fall back to 'otherGlMappings' for compatibility
const glMappings = mappings.otherGlMappingsMap || mappings.otherGlMappings;
this.setOtherGlMappings('assetsOtherGlMappings', glMappings?.assets || [], this.assetAccountOptions);
- this.setOtherGlMappings(
- 'liabilitiesOtherGlMappings',
+ // Payable is a liability row, saved under liabilities; Paid an expense row, saved under
+ // expenses. Products saved before either move carry them in the other bucket, so both buckets
+ // are read and the named entries are stripped out of either one's "Other" rows.
+ const fromLiabilities = this.extractNamedGlMapping(
this.extractNamedGlMapping(
glMappings?.liabilities || [],
SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME,
'savingsInterestPayableAccountId',
this.liabilityAccountOptions
),
- this.liabilityAccountOptions
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.paidAccountOptions
);
- this.setOtherGlMappings(
- 'expensesOtherGlMappings',
- this.extractNamedGlMapping(
- glMappings?.expenses || [],
- SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
- 'savingsInterestPaidAccountId',
- this.expenseAccountOptions
+ this.setOtherGlMappings('liabilitiesOtherGlMappings', fromLiabilities, this.liabilityAccountOptions);
+ const fromExpenses = this.extractNamedGlMapping(
+ (glMappings?.expenses || []).filter(
+ (mapping: any) => mapping?.accountName !== SAVINGS_INTEREST_PAYABLE_ACCOUNT_NAME
),
- this.expenseAccountOptions
+ SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
+ 'savingsInterestPaidAccountId',
+ this.paidAccountOptions
);
+ this.setOtherGlMappings('expensesOtherGlMappings', fromExpenses, this.expenseAccountOptions);
this.setOtherGlMappings('incomeOtherGlMappings', glMappings?.income || [], this.incomeAccountOptions);
}
}
@@ -336,11 +343,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
),
...mapOtherGlMappings('liabilitiesOtherGlMappings', this.liabilityAccountOptions)
],
+ // The backend finds Savings Interest Paid by name in any bucket; it sits under expenses
+ // because that is where the screen shows it.
expenses: [
...namedAsOtherGlMapping(
'savingsInterestPaidAccountId',
SAVINGS_INTEREST_PAID_ACCOUNT_NAME,
- this.expenseAccountOptions
+ this.paidAccountOptions
),
...mapOtherGlMappings('expensesOtherGlMappings', this.expenseAccountOptions)
],
@@ -378,14 +387,16 @@ export class SavingProductAccountingStepComponent implements OnInit {
if (this.accounting.isCashOrAccrualAccountingRuleId(accountingRule)) {
addControl('savingsReferenceAccountId', true);
addControl('savingsControlAccountId', true);
+ // The interest expense: every interest posting debits it for the product's total.
addControl('interestOnSavingsAccountId', true);
addControl('incomeFromFeeAccountId', true);
addControl('incomeFromPenaltyAccountId', true);
// Savings Interest Payable / Paid are offered under Cash too, but the backend only persists the
// core mappings for CASH_BASED, so these two ride along in otherGlMappings (see getOtherGlMappings)
- // rather than as named accounting params, which would be silently dropped. Optional by design.
- addControl('savingsInterestPayableAccountId', false);
- addControl('savingsInterestPaidAccountId', false);
+ // rather than as named accounting params, which would be silently dropped.
+ // Required: interest posting refuses a product without them rather than skipping their legs.
+ addControl('savingsInterestPayableAccountId', true);
+ addControl('savingsInterestPaidAccountId', true);
// A real accounting param, unlike the two above, so it rides out as itself rather than
// inside otherGlMappings. Optional: a product saved without one still closes.
addControl('roundingAdjustmentAccountId', false);
@@ -522,11 +533,14 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
+ // Credited with the product's total interest at posting and drawn straight back down,
+ // so it closes each posting at nil.
key: 'savingsInterestPayableAccountId',
label: 'Savings Interest Payable',
inputLabel: 'Savings interest payable',
controlName: 'savingsInterestPayableAccountId',
- options: this.liabilityAccountOptions
+ options: this.liabilityAccountOptions,
+ required: true
},
{
key: 'interestPayableAccountId',
@@ -555,6 +569,7 @@ export class SavingProductAccountingStepComponent implements OnInit {
pillClass: 'amber',
rows: [
{
+ // The interest cost: debited for the product's total at every posting.
key: 'interestOnSavingsAccountId',
label: 'Interest On Savings Account',
inputLabel: 'Interest on savings account',
@@ -563,11 +578,13 @@ export class SavingProductAccountingStepComponent implements OnInit {
required: true
},
{
+ // A pass-through between the payable and the members that closes each posting at nil.
key: 'savingsInterestPaidAccountId',
label: 'Savings Interest Paid',
inputLabel: 'Savings interest paid',
controlName: 'savingsInterestPaidAccountId',
- options: this.expenseAccountOptions
+ options: this.paidAccountOptions,
+ required: true
},
{
// Cash has no paise, so a closing balance of 9,883.79 is handed over as 9,883 and the
diff --git a/src/app/rbi-circulars/form9-return/form9-return.component.html b/src/app/rbi-circulars/form9-return/form9-return.component.html
new file mode 100644
index 000000000..98878e3e0
--- /dev/null
+++ b/src/app/rbi-circulars/form9-return/form9-return.component.html
@@ -0,0 +1,261 @@
+
+
+
+
+ @if (awaitingDate) {
+
+
Form IX — Return on Assets and Liabilities
+
+ Return code R408, filed monthly. Every figure is a closing balance as at the month end, in rupees thousands.
+
+ These lines have no GL accounts behind them, so they are left blank rather than reported as nil. If the bank
+ genuinely has nothing under a head, leaving it unmapped is correct.
+
+
+ @for (reason of data.unmetRequirements; track reason) {
+
+ @for (signatory of data.signatories; track signatory.heading) {
+
+
{{ signatory.heading }}
+
+
+ @for (field of signatoryFields(signatory); track field.caption) {
+
+
{{ field.caption }}
+
+ {{ field.value || 'Not set in Institution Profile' }}
+
+
+ }
+
+
+
+ }
+
+ }
+ }
+
+ @if (toastVisible) {
+
{{ toastMsg }}
+ }
+
diff --git a/src/app/rbi-circulars/oss-live/oss1-live.component.ts b/src/app/rbi-circulars/oss-live/oss1-live.component.ts
new file mode 100644
index 000000000..c7275be99
--- /dev/null
+++ b/src/app/rbi-circulars/oss-live/oss1-live.component.ts
@@ -0,0 +1,106 @@
+/**
+ * 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 } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Observable } from 'rxjs';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { OssLine, OssReturnBase } from './oss-return.base';
+
+/**
+ * OSS 1 — Return on Assets and Liabilities (RAL), return code R070.
+ *
+ * The bank's balance sheet restated on RBI's line structure, every figure read from the ledger as
+ * at the quarter end. Liabilities must equal assets; where they do not, the return says so rather
+ * than hiding the difference, because an imbalance is a real condition of the books that the filer
+ * has to see before filing.
+ */
+@Component({
+ selector: 'mifosx-oss1-live',
+ templateUrl: './oss-return.component.html',
+ styleUrls: ['../oss-return.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ FormsModule
+ ]
+})
+export class Oss1LiveComponent extends OssReturnBase implements OnInit {
+ protected readonly exportStem = 'RBI_OSS1_RAL';
+ readonly configRoute = '/organization/oss1-gl-config';
+ readonly configLinkLabel = 'Open OSS 1 GL Configuration';
+ readonly returnShortName = 'OSS 1';
+
+ /**
+ * Whether the two sides balance — the first thing to check on a balance sheet, and the reason
+ * the difference is stated rather than absorbed.
+ */
+ get headlineIsBad(): boolean {
+ return this.data?.balanced === false;
+ }
+
+ get headline(): string {
+ if (!this.data) {
+ return '';
+ }
+ const liabilities = this.amount(this.data.totalLiabilities);
+ const assets = this.amount(this.data.totalAssets);
+ if (!this.headlineIsBad) {
+ return (
+ `Total Liabilities ₹${liabilities} · ` +
+ `Total Assets ₹${assets} · the two sides agree`
+ );
+ }
+ return (
+ `Total Liabilities ₹${liabilities} · ` +
+ `Total Assets ₹${assets} · ` +
+ `OUT OF BALANCE by ₹${this.amount(Math.abs(this.data.imbalance ?? 0))} — ` +
+ 'resolve before filing'
+ );
+ }
+
+ protected fetch(quarterEndDate: string): Observable {
+ return this.rbiService.getOss1(quarterEndDate);
+ }
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ protected sections(): { title: string; unit: string; lines: OssLine[] }[] {
+ if (!this.data) {
+ return [];
+ }
+ return [
+ { title: 'Liabilities', unit: 'Rs in Thousands', lines: this.data.liabilities ?? [] },
+ { title: 'Assets', unit: 'Rs in Thousands', lines: this.data.assets ?? [] }
+ ];
+ }
+
+ protected totals(): { label: string; value: number | null | undefined }[] {
+ if (!this.data) {
+ return [];
+ }
+ const totals: { label: string; value: number | null | undefined }[] = [
+ { label: 'Total Liabilities', value: this.data.totalLiabilities },
+ { label: 'Total Assets', value: this.data.totalAssets }
+ ];
+ // Stated only when the two sides disagree: printing a nil difference on every balanced return
+ // would train a reviewer to skip the line that matters when it is not nil.
+ if (this.data.balanced === false) {
+ totals.push({ label: 'Difference (Assets − Liabilities)', value: this.data.imbalance });
+ }
+ return totals;
+ }
+
+ /** True when the two sides of the balance sheet disagree. */
+ get isImbalanced(): boolean {
+ return this.data?.balanced === false;
+ }
+}
diff --git a/src/app/rbi-circulars/oss-live/oss2-live.component.ts b/src/app/rbi-circulars/oss-live/oss2-live.component.ts
new file mode 100644
index 000000000..7e3ba8f90
--- /dev/null
+++ b/src/app/rbi-circulars/oss-live/oss2-live.component.ts
@@ -0,0 +1,88 @@
+/**
+ * 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 } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { FaIconComponent } from '@fortawesome/angular-fontawesome';
+import { Observable } from 'rxjs';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { OssLine, OssReturnBase } from './oss-return.base';
+
+/**
+ * OSS 2 — Return on Earnings (RoE), return code R071.
+ *
+ * The bank's profit and loss account on RBI's line structure. Unlike OSS 1 these lines report the
+ * movement over the quarter rather than a closing balance: the same accounts read as balances
+ * would carry forward every earlier quarter's income as well.
+ */
+@Component({
+ selector: 'mifosx-oss2-live',
+ templateUrl: './oss-return.component.html',
+ styleUrls: ['../oss-return.scss'],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FaIconComponent,
+ FormsModule
+ ]
+})
+export class Oss2LiveComponent extends OssReturnBase implements OnInit {
+ protected readonly exportStem = 'RBI_OSS2_RoE';
+ readonly configRoute = '/organization/oss2-gl-config';
+ readonly configLinkLabel = 'Open OSS 2 GL Configuration';
+ readonly returnShortName = 'OSS 2';
+
+ /** A loss is worth flagging; it is a result to explain, not merely a number to read. */
+ get headlineIsBad(): boolean {
+ return (this.data?.operatingProfit ?? 0) < 0;
+ }
+
+ get headline(): string {
+ if (!this.data) {
+ return '';
+ }
+ const result = this.data.operatingProfit ?? 0;
+ return (
+ `Total Income ₹${this.amount(this.data.totalIncome)} · ` +
+ `Total Expenditure ₹${this.amount(this.data.totalExpenditure)} · ` +
+ `${result >= 0 ? 'Operating Profit' : 'Operating Loss'} ` +
+ `₹${this.amount(Math.abs(result))} for the quarter`
+ );
+ }
+
+ protected fetch(quarterEndDate: string): Observable {
+ return this.rbiService.getOss2(quarterEndDate);
+ }
+
+ ngOnInit(): void {
+ this.load();
+ }
+
+ protected sections(): { title: string; unit: string; lines: OssLine[] }[] {
+ if (!this.data) {
+ return [];
+ }
+ return [
+ { title: 'Earnings', unit: 'Rs in Thousands', lines: this.data.earnings ?? [] },
+ { title: 'Expenditure', unit: 'Rs in Thousands', lines: this.data.expenses ?? [] }
+ ];
+ }
+
+ protected totals(): { label: string; value: number | null | undefined }[] {
+ if (!this.data) {
+ return [];
+ }
+ return [
+ { label: 'Total Income', value: this.data.totalIncome },
+ { label: 'Total Expenditure', value: this.data.totalExpenditure },
+ {
+ label: (this.data.operatingProfit ?? 0) >= 0 ? 'Operating Profit' : 'Operating Loss',
+ value: this.data.operatingProfit
+ }
+ ];
+ }
+}
diff --git a/src/app/rbi-circulars/oss-return.scss b/src/app/rbi-circulars/oss-return.scss
index ade2b179c..5b4f2989b 100644
--- a/src/app/rbi-circulars/oss-return.scss
+++ b/src/app/rbi-circulars/oss-return.scss
@@ -567,3 +567,92 @@ td.gl-diff {
grid-template-columns: 1fr;
}
}
+
+/* ── Live OSS returns (OSS 1, OSS 2) ────────────────────────────────────────
+ These read their figures from the ledger rather than from a filed workbook, so they carry two
+ things a static return does not: a note on which accounts a line came from, and a statement of
+ what is not mapped. */
+
+/* Caveats about the return as a whole: lines with nothing behind them. Sits above the sheets
+ because a blank line is a fact about the return, not about the sheet it happens to be on. */
+.oss-caveat {
+ background: var(--o-amber-l);
+ border: 1px solid #e3d27a;
+ border-radius: 8px;
+ padding: 12px 14px;
+ margin-bottom: 14px;
+ color: var(--o-amber-d);
+ font-size: 12.5px;
+}
+
+.oss-caveat-h {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 13px;
+}
+
+.oss-caveat-lead {
+ margin: 6px 0 0;
+ line-height: 1.6;
+ opacity: 0.9;
+}
+
+.oss-caveat-list {
+ margin: 8px 0 8px 18px;
+ padding: 0;
+
+ li {
+ margin-bottom: 4px;
+ }
+}
+
+.oss-caveat-link {
+ display: inline-block;
+ margin-top: 4px;
+ color: var(--o-blue);
+ font-weight: 600;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+}
+
+/* A figure the return could not produce, or an account left out of one. Marked rather than merely
+ blank, so it reads as deliberately absent instead of as a nil the bank asserted. */
+.oss-unset {
+ color: var(--o-amber-d);
+ font-style: italic;
+ font-weight: 500;
+}
+
+/* The GL accounts behind a line, indented under it. */
+.oss-subline {
+ td {
+ font-size: 11.5px;
+ color: var(--o-g500);
+ }
+
+ &--excluded td {
+ color: var(--o-amber-d);
+ }
+}
+
+.oss-gl-code {
+ font-family: 'Roboto Mono', monospace;
+ font-size: 10.5px;
+ background: var(--o-g50);
+ border: 1px solid var(--o-g200);
+ border-radius: 4px;
+ padding: 1px 5px;
+ margin-right: 6px;
+}
+
+/* How the figures on a sheet were arrived at — the dates they were read on. */
+.oss-note {
+ margin: 10px 2px 0;
+ font-size: 11.5px;
+ color: var(--o-g500);
+ line-height: 1.6;
+}
diff --git a/src/app/rbi-circulars/oss-workbook.ts b/src/app/rbi-circulars/oss-workbook.ts
index edddbe9ac..8f82d1c2f 100644
--- a/src/app/rbi-circulars/oss-workbook.ts
+++ b/src/app/rbi-circulars/oss-workbook.ts
@@ -27,6 +27,17 @@ export function solidFill(argb: string): ExcelJS.Fill {
return { type: 'pattern', pattern: 'solid', fgColor: { argb } };
}
+/**
+ * The diagonal hatching RBI's templates put on a cell that is waiting to be filled in by hand.
+ *
+ * It reads differently from an empty cell, and the difference matters on a statutory form: an empty
+ * cell says nothing was reported, hatching says someone still has to report it. The templates use
+ * it on the signatory block and on the particulars a bank writes in itself.
+ */
+export function hatchFill(): ExcelJS.Fill {
+ return { type: 'pattern', pattern: 'lightUp', fgColor: { argb: 'FF808080' }, bgColor: { argb: 'FFFFFFFF' } };
+}
+
/** Dark-blue full-width banner that heads every sheet in the RBI templates. */
export function addBanner(sheet: ExcelJS.Worksheet, title: string, lastCol: string): void {
const row = sheet.addRow([title]);
diff --git a/src/app/rbi-circulars/rbi-circulars-routing.module.ts b/src/app/rbi-circulars/rbi-circulars-routing.module.ts
index 045ce0f06..650354f34 100644
--- a/src/app/rbi-circulars/rbi-circulars-routing.module.ts
+++ b/src/app/rbi-circulars/rbi-circulars-routing.module.ts
@@ -26,16 +26,30 @@ const routes: Routes = [
data: { title: 'AML Rules Master', breadcrumb: 'AML Rules Master' }
},
{
+ // OSS 1 computed live from the ledger. Lazy — the ExcelJS workbook builder should not weigh
+ // down the eager bundle.
path: 'rbi/oss1',
- // Lazy — the ExcelJS workbook builder should not weigh down the eager bundle.
- loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent),
+ loadComponent: () => import('./oss-live/oss1-live.component').then((m) => m.Oss1LiveComponent),
data: { title: 'OSS 1 — Return on Assets and Liabilities', breadcrumb: 'OSS 1 (RAL)' }
},
{
path: 'rbi/oss2',
- loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
+ loadComponent: () => import('./oss-live/oss2-live.component').then((m) => m.Oss2LiveComponent),
data: { title: 'OSS 2 — Return on Earnings', breadcrumb: 'OSS 2 (RoE)' }
},
+ {
+ // The filed workbooks as they stood before the returns were computed from the ledger. Kept
+ // reachable so a figure on a live return can be checked against what was actually filed for
+ // an earlier quarter; they read from static data and do not move.
+ path: 'rbi/oss1-filed',
+ loadComponent: () => import('./oss1-ral/oss1-ral.component').then((m) => m.Oss1RalComponent),
+ data: { title: 'OSS 1 — As filed (static)', breadcrumb: 'OSS 1 (as filed)' }
+ },
+ {
+ path: 'rbi/oss2-filed',
+ loadComponent: () => import('./oss2-roe/oss2-roe.component').then((m) => m.Oss2RoeComponent),
+ data: { title: 'OSS 2 — As filed (static)', breadcrumb: 'OSS 2 (as filed)' }
+ },
{
path: 'rbi/share-capital',
// Lazy for the same reason as the OSS returns — the ExcelJS workbook builder should not
@@ -48,7 +62,31 @@ const routes: Routes = [
path: 'rbi/oss3',
loadComponent: () => import('./oss3-raq/oss3-raq.component').then((m) => m.Oss3RaqComponent),
data: { title: 'OSS 3 — Return on Asset Quality', breadcrumb: 'OSS 3 (RAQ-UCB)' }
+ },
+ {
+ // CRR return, filed twice a month. Lazy like the other returns — it pulls in ExcelJS.
+ path: 'rbi/form1',
+ loadComponent: () => import('./reserve-return/form1-crr.component').then((m) => m.Form1CrrComponent),
+ data: { title: 'Form 1 — Cash Reserve Ratio', breadcrumb: 'Form 1 (CRR)' }
+ },
+ // Form 9 (SLR) has no route of its own. Section 24 is settled on the statutory Form 1 at Part C,
+ // charged on the same liabilities Part B charges the cash reserve on. A second screen computing
+ // the same ratio from a month-end base disagreed with it, and both were headed Form 9 — whichever
+ // a filer read, they had no way to know the other said something else.
+ {
+ // Form IX — the Return on Assets and Liabilities, code R408, filed monthly.
+ //
+ // A DIFFERENT return from Form 9 (SLR) above despite the name. That one reports one ratio;
+ // this one reports the whole balance sheet, in rupees thousands, and its two sides must
+ // agree. Its own route rather than a tab on rbi/form9 for exactly that reason — they answer
+ // different questions and share no figures.
+ path: 'rbi/form9-return',
+ loadComponent: () => import('./form9-return/form9-return.component').then((m) => m.Form9ReturnComponent),
+ data: { title: 'Form IX — Return on Assets and Liabilities', breadcrumb: 'Form IX (R408)' }
}
+ // The statutory Form 1 has no route of its own: it is a tab on rbi/form1. Both are Form 1 for
+ // one bank and one period, and two routes made a filer guess which held which — one could be
+ // read while the other was believed to be on screen.
])
];
diff --git a/src/app/rbi-circulars/rbi-circulars.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts
index 33446834c..7bffbc36f 100644
--- a/src/app/rbi-circulars/rbi-circulars.service.ts
+++ b/src/app/rbi-circulars/rbi-circulars.service.ts
@@ -60,4 +60,204 @@ export class RbiCircularsService {
getShareCapitalReturnYears(): Observable {
return this.http.get('/shareproducts/capital-return/financial-years');
}
+
+ /**
+ * Form 1 — CRR, computed live from the ledger for the fortnight the reporting date falls in.
+ *
+ * The DTL base and the balances maintained are read as at different dates: the base lags by 45
+ * days (snapped back to a reporting Friday), the holdings are as at the reporting date itself.
+ * The response's `basis` block states both, so the lag is auditable rather than implied.
+ *
+ * @param reportingDate yyyy-MM-dd. Defaults to the current business date on the backend.
+ * @param officeId Optional — scopes every figure to one office.
+ */
+ getForm1(reportingDate?: string, baseDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ // The base date can be given outright. The 45-day lag lands where it lands, and a bank reading
+ // an earlier fortnight — or correcting a filing — needs to name the date the base is struck at
+ // rather than work backwards from it. Left blank it is derived from the lag as before.
+ if (baseDate) {
+ params = params.set('baseDate', baseDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form1', { params });
+ }
+
+ /**
+ * Form 9 — SLR, computed live on the month-end position. Any date within a month resolves to that
+ * month's end, since the return reports the position at close.
+ */
+ getForm9(reportingDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form9', { params });
+ }
+
+ /**
+ * Form 9 in the shape it is filed — the statutory statement of compliance with section 24.
+ *
+ * A different statement from `getForm9`, not a reformatting: three dated columns rather than one,
+ * and the ratio charged on the last Friday of the second preceding fortnight rather than on the
+ * month end itself.
+ */
+ getForm9Statutory(reportingDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form9-statutory', { params });
+ }
+
+ /**
+ * Form IX — the Return on Assets and Liabilities a co-operative bank files monthly, code R408.
+ *
+ * A different return from `getForm9` despite the name: that one reports the SLR ratio, this one
+ * reports the whole balance sheet. Every figure comes back in rupees thousands, with the rupee
+ * figure alongside it so a line can still be reconciled against the ledger.
+ *
+ * @param reportingDate yyyy-MM-dd — any date in the month; the return reports that month's end.
+ */
+ getForm9Return(reportingDate?: string, officeId?: number): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form9-return', { params });
+ }
+
+ /**
+ * OSS 1 — Return on Assets and Liabilities, computed live from the ledger.
+ *
+ * Every figure is a closing balance as at the quarter end; the bank's own particulars come from
+ * the institution profile. Any date inside a quarter resolves to that quarter, so a caller never
+ * has to know which day a quarter ends on.
+ *
+ * @param quarterEndDate yyyy-MM-dd — any date in the quarter. Defaults to the business date.
+ * @param officeId Optional — scopes every figure to one office.
+ */
+ getOss1(quarterEndDate?: string, officeId?: number): Observable {
+ return this.http.get('/ossreturn/oss1', { params: this.ossParams(quarterEndDate, officeId) });
+ }
+
+ /**
+ * OSS 2 — Return on Earnings, computed live from the ledger.
+ *
+ * Earnings lines report the movement over the quarter rather than a closing balance, so the same
+ * accounts read as balances would carry forward every earlier quarter's income as well.
+ */
+ getOss2(quarterEndDate?: string, officeId?: number): Observable {
+ return this.http.get('/ossreturn/oss2', { params: this.ossParams(quarterEndDate, officeId) });
+ }
+
+ /**
+ * Form 1 as a co-operative bank files it — Banking Regulation (Co-operative Societies) Rules
+ * 1966, Rule 5, sections 18(1) and 24(3).
+ *
+ * A different statement from `getForm1`, not a reformatting: three columns rather than one, both
+ * ratios settled on the same liabilities, and those liabilities taken from the last Friday of the
+ * second preceding fortnight rather than a 45-day lag.
+ */
+ getForm1Statutory(
+ reportingDate?: string,
+ firstColumnDate?: string,
+ secondColumnDate?: string,
+ officeId?: number,
+ firstBaseDate?: string,
+ secondBaseDate?: string
+ ): Observable {
+ let params = new HttpParams();
+ if (reportingDate) {
+ params = params.set('reportingDate', reportingDate);
+ }
+ // Both fortnight closes can be given outright. A bank filing August wants 15/08 and 31/08 on
+ // the face of the form whatever today is, and one date cannot produce a pair that spans a month
+ // boundary or skips a fortnight — which a late or corrected filing needs.
+ if (firstColumnDate) {
+ params = params.set('firstColumnDate', firstColumnDate);
+ }
+ if (secondColumnDate) {
+ params = params.set('secondColumnDate', secondColumnDate);
+ }
+ // The base each column's ratios are charged on. Left blank the rules fix it — the last Friday
+ // of that column's second preceding fortnight — so a filer only names one where the rule
+ // produces a date their ledger cannot answer, such as a period before the bank migrated.
+ if (firstBaseDate) {
+ params = params.set('firstBaseDate', firstBaseDate);
+ }
+ if (secondBaseDate) {
+ params = params.set('secondBaseDate', secondBaseDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return this.http.get('/rbireservereturn/form1-statutory', { params });
+ }
+
+ // ── Saved returns ──
+
+ /**
+ * Returns saved earlier, newest first. A saved return is served from the snapshot taken when it
+ * was saved, never recomputed — a backdated entry or a changed mapping would otherwise produce a
+ * different statement from the one that was reviewed.
+ */
+ getSavedReturns(formCode?: string): Observable {
+ let params = new HttpParams();
+ if (formCode) {
+ params = params.set('formCode', formCode);
+ }
+ return this.http.get('/rbiformafiling', { params });
+ }
+
+ /** One saved return, with the stored snapshot, so the screen renders what was saved. */
+ getSavedReturn(filingId: number): Observable {
+ return this.http.get(`/rbiformafiling/${filingId}`);
+ }
+
+ /** Keeps the return currently on screen. Re-saving the same form and date replaces it. */
+ saveReturn(formCode: string, reportingDate: string, report: any, remarks?: string): Observable {
+ return this.http.post('/rbiformafiling', {
+ formCode,
+ reportingDate,
+ report,
+ remarks,
+ locale: 'en',
+ dateFormat: 'yyyy-MM-dd'
+ });
+ }
+
+ /** Moves a saved return along its filing lifecycle; the snapshot itself is untouched. */
+ updateSavedReturnStatus(filingId: number, status: string, remarks?: string): Observable {
+ return this.http.post(`/rbiformafiling/${filingId}/status`, { status, remarks });
+ }
+
+ deleteSavedReturn(filingId: number): Observable {
+ return this.http.delete(`/rbiformafiling/${filingId}`);
+ }
+
+ private ossParams(quarterEndDate?: string, officeId?: number): HttpParams {
+ let params = new HttpParams();
+ if (quarterEndDate) {
+ params = params.set('quarterEndDate', quarterEndDate);
+ }
+ if (officeId != null) {
+ params = params.set('officeId', String(officeId));
+ }
+ return params;
+ }
}
diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
new file mode 100644
index 000000000..b275460dd
--- /dev/null
+++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
@@ -0,0 +1,147 @@
+/**
+ * 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 } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import { Observable, catchError, of } from 'rxjs';
+import * as ExcelJS from 'exceljs';
+import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { addBanner, boxAllCells, downloadWorkbook, newWorkbook, solidFill, XL } from '../oss-workbook';
+import { Form1Part, Form1Statutory, ReserveReturnBase } from './reserve-return.base';
+
+/**
+ * RBI Form 1 — Cash Reserve Ratio, filed twice a month.
+ *
+ * The return compares the CRR required on an earlier fortnight's Demand and Time Liabilities
+ * against the cash and bank balances held today. The two sides are deliberately read on different
+ * dates — see the basis panel on the screen, which states both.
+ *
+ * The statutory Form 1 — the return as a co-operative bank actually files it, three dated columns
+ * with both ratios settled on one base — is a tab on this same screen rather than a page of its
+ * own. Both are Form 1 for the same bank and the same period; splitting them across two routes
+ * meant a filer had to know which URL held which, and could read one while believing they were
+ * looking at the other.
+ */
+@Component({
+ selector: 'mifosx-form1-crr',
+ templateUrl: './reserve-return.component.html',
+ styleUrls: [
+ './reserve-return.component.scss',
+ './form1-statutory.component.scss'
+ ],
+ imports: [
+ ...STANDALONE_SHARED_IMPORTS,
+ FormsModule
+ ]
+})
+export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
+ protected readonly exportStem = 'RBI_Form1_CRR';
+ // The statutory form's code, because that is what this screen saves and therefore what its saved
+ // list must ask for. Left as FORM_1 the list filtered on a code nothing writes any more and came
+ // back empty, which reads as "nothing has ever been saved" rather than as a mismatch.
+ protected readonly formCode = 'FORM_1_STATUTORY';
+
+ /** Returns filed before the screen moved to the statutory form, so they stay in the list. */
+ protected override readonly legacyFormCodes = ['FORM_1'];
+
+ override readonly hasStatutoryForm = true;
+ override readonly hasBaseDate = true;
+
+ /**
+ * The statutory form is the only view of Form 1.
+ *
+ * There were two: a working report charging the ratio on a 45-day lag, and the statutory form
+ * charging it on the last Friday of the second preceding fortnight. Both were headed Form 1, both
+ * were computed from the same ledger, and they disagreed — because the rules they follow differ.
+ * One screen showing two different answers to "what is our CRR position" is worse than no screen:
+ * whichever a filer read, they had no way to know the other existed and said something else. What
+ * gets filed is the statutory form, so that is what the screen shows.
+ */
+ override activeTab: 'report' | 'statutory' | 'saved' = 'statutory';
+
+ protected override fetchStatutory(): Observable {
+ return this.rbiService.getForm1Statutory(
+ this.statutoryDate,
+ this.statutoryFirstColumn || undefined,
+ this.statutorySecondColumn || undefined,
+ undefined,
+ this.statutoryFirstBase || undefined,
+ this.statutorySecondBase || undefined
+ );
+ }
+
+ protected fetch(reportingDate: string): Observable {
+ return this.rbiService.getForm1(reportingDate, this.baseDate || undefined);
+ }
+
+ ngOnInit(): void {
+ // No load() here — the screen asks for a reporting date first, because which fortnight the
+ // return covers and which base date it reads follow from that one field.
+ this.loadSaved();
+ }
+ /**
+ * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
+ * they are about to run. Fortnights close on the 15th and on the month end.
+ */
+ get statutoryFortnight(): string {
+ if (!this.statutoryDate) {
+ return '';
+ }
+ const d = new Date(this.statutoryDate + 'T00:00:00');
+ const first = d.getDate() <= 15;
+ const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
+ return `${first ? 1 : 16}–${end} ${month}`;
+ }
+
+ /**
+ * Fills both columns with a whole month's fortnight closes — 15th and month end — which is the
+ * pair a filed return almost always carries.
+ */
+ fillStatutoryMonth(): void {
+ const d = this.statutoryDate ? new Date(this.statutoryDate + 'T00:00:00') : new Date();
+ const year = d.getFullYear();
+ const month = d.getMonth() + 1;
+ const monthEnd = new Date(year, month, 0).getDate();
+ this.statutoryFirstColumn = this.asDate(year, month, 15);
+ // Filling the CURRENT month would put a month end that has not arrived into the second column,
+ // and the form would be refused the moment it was run. Capped at today, so the quick fill
+ // always produces a pair that can actually be reported.
+ const filled = this.asDate(year, month, monthEnd);
+ this.statutorySecondColumn = filled > this.today ? this.today : filled;
+ this.statutoryDate = this.statutorySecondColumn;
+ // The bases follow the closes, so a quick fill leaves a complete form rather than two dates
+ // and two empty boxes the filer has to work out for themselves.
+ this.onStatutoryColumnChange('first');
+ this.onStatutoryColumnChange('second');
+ }
+
+ /** Clears both columns and the bases that follow from them. */
+ clearStatutoryColumns(): void {
+ this.statutoryFirstColumn = '';
+ this.statutorySecondColumn = '';
+ this.statutoryFirstBase = '';
+ this.statutorySecondBase = '';
+ }
+
+ private asDate(year: number, month: number, day: number): string {
+ return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ }
+ /**
+ * Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
+ *
+ * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
+ * snapshot, and a statutory statement filed under the working report's code would be opened by
+ * the reader built for the other shape.
+ */
+ protected override saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
+ return this.statutory
+ ? { formCode: this.statutory.formCode, reportingDate: this.statutory.reportingDate, report: this.statutory }
+ : null;
+ }
+}
diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
new file mode 100644
index 000000000..938ff68c2
--- /dev/null
+++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
@@ -0,0 +1,509 @@
+/**
+ * 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/.
+ */
+$primary: #1353a0;
+$border: #e2e8f0;
+$gray-50: #f8f9fa;
+$gray-200: #e8eaed;
+$gray-500: #9aa0a6;
+$gray-900: #202124;
+$green: #137333;
+$amber: #b06000;
+$red: #c5221f;
+
+.f1s-container {
+ max-width: 1180px;
+ margin: 0 auto;
+ padding: 20px 16px;
+}
+
+.f1s-loading {
+ padding: 36px;
+ text-align: center;
+ color: $gray-500;
+ font-size: 13px;
+}
+
+.f1s-alert {
+ padding: 12px 16px;
+ border-radius: 8px;
+ font-size: 12.5px;
+ margin-bottom: 16px;
+ line-height: 1.6;
+
+ &--error {
+ background: #fce8e6;
+ color: $red;
+ }
+
+ &--warn {
+ background: #fef7e0;
+ color: $amber;
+ }
+}
+
+.f1s-unmet {
+ margin: 8px 0 0 18px;
+ padding: 0;
+}
+
+// ── Date prompt ──
+// Shown before anything is fetched. Centred and roomy rather than a strip at the top, because at
+// this point choosing the date is the whole of the screen's job.
+
+.f1s-prompt {
+ max-width: 640px;
+ margin: 48px auto;
+ background: #fff;
+ border: 1px solid $border;
+ border-radius: 12px;
+ padding: 32px;
+ text-align: center;
+
+ &__title {
+ margin: 0 0 6px;
+ font-size: 18px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ &__sub {
+ margin: 0 0 24px;
+ font-size: 12.5px;
+ color: $gray-500;
+ line-height: 1.7;
+ }
+
+ // The date pairs. Each row holds two, and the rows stack — so the closes sit directly above the
+ // bases they produce, and a filer reads down a column rather than across a wrapped line.
+ &__row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 12px;
+ flex-wrap: wrap;
+
+ & + & {
+ margin-top: 10px;
+ }
+
+ // The bases are filled from the closes above rather than typed, so they are set back a shade -
+ // enough to read as "these follow from those" without looking disabled, since they can be
+ // changed and sometimes must be.
+ &--derived {
+ opacity: 0.85;
+ }
+ }
+
+ &__date {
+ width: 220px;
+ }
+
+ // The button on its own line under the pairs, centred on them rather than trailing the last one.
+ &__go {
+ margin-top: 16px;
+ display: flex;
+ justify-content: center;
+ }
+
+ &__hint {
+ margin: 14px 0 0;
+ font-size: 12.5px;
+ color: $primary;
+ }
+
+ &__quick {
+ margin-top: 18px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 8px;
+ font-size: 12px;
+ color: $gray-500;
+
+ button {
+ font-size: 12px !important;
+ min-width: 0 !important;
+ }
+ }
+}
+
+// The strip above the sheet when the statutory form is shown as a tab: says what period is on
+// screen and offers the way back to the date prompt.
+.f1s-bar {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+ flex-wrap: wrap;
+ margin-bottom: 14px;
+
+ &__date {
+ font-size: 12.5px;
+ font-weight: 600;
+ color: $primary;
+ }
+}
+
+// ── Toolbar ──
+
+.f1s-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 16px;
+ flex-wrap: wrap;
+ margin-bottom: 16px;
+
+ &__title {
+ margin: 0;
+ font-size: 18px;
+ font-weight: 600;
+ color: $gray-900;
+ }
+
+ &__ref {
+ margin: 2px 0 0;
+ font-size: 12px;
+ color: $gray-500;
+ }
+
+ &__actions {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+
+ i {
+ margin-right: 6px;
+ }
+ }
+}
+
+.f1s-date {
+ width: 180px;
+}
+
+// ── The sheet ──
+// Bordered and squared off rather than card-like: this is a statutory return that gets printed and
+// signed, and it should read on screen as the same document that comes out of the printer.
+
+.f1s-sheet {
+ background: #fff;
+ border: 1px solid $gray-900;
+ padding: 20px;
+
+ &__head {
+ text-align: center;
+ border-bottom: 2px solid $gray-900;
+ padding-bottom: 12px;
+ margin-bottom: 16px;
+ }
+
+ &__title {
+ font-size: 15px;
+ font-weight: 700;
+ color: $gray-900;
+ text-transform: uppercase;
+ }
+
+ &__ref {
+ margin-top: 4px;
+ font-size: 12px;
+ color: $gray-500;
+ }
+}
+
+.f1s-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-size: 12.5px;
+
+ th,
+ td {
+ border: 1px solid $gray-200;
+ padding: 6px 10px;
+ vertical-align: top;
+ }
+
+ th {
+ background: $gray-50;
+ font-weight: 700;
+ font-size: 11.5px;
+ color: $gray-900;
+ text-align: center;
+ border-color: $gray-900;
+ }
+
+ &__particulars {
+ text-align: left !important;
+ width: 52%;
+ }
+
+ &__num {
+ text-align: right;
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+ }
+
+ // A column heading is a date, not a figure, and sits centred over the column it heads. Qualified
+ // with `th` so it beats `&__num` above on specificity rather than on source order.
+ th#{&}__num {
+ text-align: center;
+ }
+
+ // The second line of a heading — what the column IS, under the date it carries. Without it the
+ // three dates read as three closes, and the base the ratios are charged on looks like one more
+ // reporting date.
+ &__colnote {
+ display: block;
+ margin-top: 2px;
+ font-weight: 400;
+ font-size: 9.5px;
+ letter-spacing: 0.02em;
+ text-transform: uppercase;
+ color: $gray-500;
+ }
+
+ // The date the column's ratios are charged on. In the heading rather than in a column of its own,
+ // so a reader sees which base produced the required amounts below without the base's balances
+ // sitting beside the closes' as though they were a third period.
+ &__colbase {
+ display: block;
+ margin-top: 1px;
+ font-weight: 400;
+ font-size: 9px;
+ letter-spacing: 0.02em;
+ color: $primary;
+ }
+}
+
+// Part banner — PART A / PART B / PART C.
+.f1s-part td {
+ background: $primary;
+ color: #fff;
+ font-weight: 700;
+ font-size: 12px;
+ letter-spacing: 0.03em;
+ border-color: $primary;
+}
+
+// Section caption inside a part, e.g. "(a) Demand Liabilities".
+.f1s-heading td {
+ background: $gray-50;
+ font-weight: 700;
+}
+
+.f1s-total td {
+ font-weight: 700;
+ background: #e8f0fe;
+}
+
+.f1s-total--result td {
+ background: #e6f4ea;
+}
+
+// A shortfall reads as one rather than as a number with a minus sign.
+.f1s-neg {
+ color: $red;
+}
+
+.f1s-subnote td {
+ font-size: 11px;
+ font-style: italic;
+ color: $gray-500;
+ background: #fffdf5;
+}
+
+// The form nests to three levels — (a), then (i), then the figure.
+.f1s-indent-1 {
+ padding-left: 26px !important;
+}
+
+.f1s-indent-2 {
+ padding-left: 46px !important;
+}
+
+.f1s-badge {
+ margin-left: 8px;
+ font-size: 10px;
+ font-weight: 600;
+ background: #fef7e0;
+ color: $amber;
+ border-radius: 20px;
+ padding: 1px 8px;
+ white-space: nowrap;
+}
+
+// ── Day by day ──
+// Outside the form's own sheet, because it is not part of what gets filed: the form reports two
+// closes, and this is the working behind them.
+.f1s-daily {
+ margin-top: 24px;
+ background: #fff;
+ border: 1px solid $border;
+ border-radius: 10px;
+ padding: 18px 20px;
+
+ &__title {
+ margin: 0 0 4px;
+ font-size: 14px;
+ font-weight: 700;
+ color: $gray-900;
+ }
+
+ // A day short is a breach, and on thirty rows it is exactly what the eye skips. Said in the
+ // heading so it is read before the table rather than found in it.
+ &__flag {
+ display: inline-block;
+ margin-left: 10px;
+ padding: 2px 8px;
+ font-size: 10.5px;
+ font-weight: 700;
+ color: $red;
+ background: #fce8e6;
+ border-radius: 4px;
+ }
+
+ &__hint {
+ margin: 0 0 14px;
+ font-size: 11.5px;
+ color: $gray-500;
+ }
+
+ &__stats {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 12px;
+ margin-bottom: 16px;
+ }
+
+ // The base a day was charged on. Set back because it repeats for every day of a fortnight and
+ // would otherwise compete with the figures it qualifies.
+ &__base {
+ color: $gray-500;
+ font-size: 11px;
+ }
+
+ // A day the bank's own liabilities moved. The whole point of carrying a daily figure is to find
+ // these, and on thirty rows of near-identical numbers they are invisible unless marked.
+ &__moved {
+ font-weight: 700;
+ color: $primary;
+ background: #f0f6ff;
+ }
+
+ &__table {
+ font-size: 11.5px;
+
+ th {
+ font-size: 10.5px;
+ }
+ }
+
+ &--short td {
+ background: #fff6f6;
+ }
+}
+
+.f1s-stat {
+ border: 1px solid $border;
+ border-radius: 8px;
+ padding: 10px 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+
+ &__label {
+ font-size: 10px;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: $gray-500;
+ font-weight: 600;
+ }
+
+ &__value {
+ font-size: 17px;
+ font-weight: 600;
+ color: $gray-900;
+ font-variant-numeric: tabular-nums;
+ }
+
+ &__sub {
+ font-size: 10.5px;
+ color: $gray-500;
+ }
+}
+
+.f1s-sign {
+ margin-top: 56px;
+ text-align: right;
+
+ &__line {
+ display: inline-block;
+ border-top: 1px solid $gray-900;
+ padding-top: 6px;
+ font-size: 12px;
+ font-weight: 600;
+ min-width: 220px;
+ text-align: center;
+ }
+}
+
+.f1s-toast {
+ position: fixed;
+ bottom: 24px;
+ left: 50%;
+ transform: translateX(-50%);
+ background: $gray-900;
+ color: #fff;
+ font-size: 12.5px;
+ padding: 10px 18px;
+ border-radius: 8px;
+ z-index: 1000;
+}
+
+// ── Print ──
+// The browser's print dialog is also its save-as-PDF, so one stylesheet serves both. The sheet is
+// already laid out as the filed form, so printing only has to drop the controls and keep the
+// shading that carries meaning.
+@media print {
+ .f1s-noprint {
+ display: none !important;
+ }
+
+ .f1s-container {
+ max-width: none;
+ padding: 0;
+ }
+
+ .f1s-sheet {
+ border: none;
+ padding: 0;
+ }
+
+ .f1s-table thead {
+ display: table-header-group;
+ }
+
+ .f1s-table tr {
+ break-inside: avoid;
+ }
+
+ .f1s-toast {
+ display: none !important;
+ }
+
+ .f1s-container,
+ .f1s-sheet,
+ .f1s-table,
+ th,
+ td {
+ -webkit-print-color-adjust: exact;
+ print-color-adjust: exact;
+ }
+}
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
new file mode 100644
index 000000000..cf3c439e1
--- /dev/null
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
@@ -0,0 +1,2133 @@
+/**
+ * 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 { inject } from '@angular/core';
+import { Observable, catchError, forkJoin, of } from 'rxjs';
+import * as ExcelJS from 'exceljs';
+import { RbiCircularsService } from '../rbi-circulars.service';
+import { addBanner, boxAllCells, downloadWorkbook, hatchFill, newWorkbook, solidFill, XL } from '../oss-workbook';
+
+/** One configured figure on the return, with the accounts it was derived from. */
+export interface ReserveLine {
+ label: string;
+ configKey: string;
+ configured: boolean;
+ asOnDate: string;
+ amount: number | null;
+ /**
+ * The same head read at both of the return's dates, so a row can be compared across them. The
+ * return is built from figures struck at two different dates — the base at its lagged date, the
+ * holdings at the reporting date — and without both a reader cannot tell a real movement from the
+ * two dates simply being different.
+ */
+ baseAmount?: number | null;
+ currentAmount?: number | null;
+ movement?: number | null;
+ /**
+ * The accounts the figure was derived from. `balance` is null and `excluded` set when an account
+ * is still pinned to the role but no longer countable — disabled, or turned into a header whose
+ * balance would double-count its children.
+ */
+ accounts: ReserveLineAccount[];
+}
+
+/**
+ * One account behind a line. `accountBalance` is what the ledger holds; `balance` is the portion
+ * this head takes of it. The two differ whenever `weightPercentage` is under 100 — RBI treats part
+ * of a savings balance as a demand liability and the rest as time, so the same account can appear
+ * twice under one head with a different share each time.
+ */
+export interface ReserveLineAccount {
+ glAccountId: number;
+ glCode: string;
+ name: string;
+ /** The account's own balance, before the head's share is applied. */
+ accountBalance?: number | null;
+ /** The share of that balance this head takes. 100 means the whole of it. */
+ weightPercentage?: number | null;
+ /** DEMAND or TIME on a liability head; null elsewhere. */
+ liabilityClass?: string | null;
+ balance: number | null;
+ excluded?: 'disabled' | 'header';
+ /** The mapping row this came from — one account can be mapped to a head more than once. */
+ mappingId?: number;
+ /**
+ * The same account read at each of the return's two dates, so the breakdown explains both
+ * columns rather than only the one its line is struck at. Null where the account was not mapped
+ * at the other date — it did not hold nothing, it was not on the return.
+ */
+ baseBalance?: number | null;
+ currentBalance?: number | null;
+ baseAccountBalance?: number | null;
+ currentAccountBalance?: number | null;
+ movement?: number | null;
+}
+
+// ── The statutory Form 1 ──
+//
+// Shapes for the return as a co-operative bank files it: three dated columns, Part A establishing
+// the liabilities and Parts B and C settling the two ratios on that same base. It is shown as a tab
+// on the Form 1 screen rather than as a page of its own.
+
+/** One printed row: a heading, a figure line, or a total. */
+export interface Form1Row {
+ label: string;
+ configKey?: string | null;
+ indent?: number;
+ heading?: boolean;
+ total?: boolean;
+ configured?: boolean;
+ values?: number[];
+ /** On the base-date row, the date each column's liabilities were read at. */
+ baseDates?: string[];
+}
+
+export interface Form1Part {
+ title: string;
+ lines: Form1Row[];
+ required?: number[];
+ maintained?: number[];
+ surplus?: number[];
+ baseDates?: string[];
+ baseDateLiabilities?: number[];
+}
+
+/** One day of the period, with both ratios measured against the base in force that day. */
+export interface Form1DailyRow {
+ date: string;
+ /** The date this day's ratios were charged on — it moves at each fortnight boundary. */
+ baseDate: string;
+ /**
+ * The net liabilities that date held, which both requirements below are struck on.
+ *
+ * Absent on a return saved before the backend stated it, so readers treat it as optional rather
+ * than printing a zero where an older snapshot simply never carried the figure.
+ */
+ baseLiabilities?: number;
+ /**
+ * The day's own net liabilities, which move daily where the base holds still for a fortnight.
+ *
+ * Optional for the same reason as the base: a return saved before the backend stated it carries
+ * no figure, and a zero would read as a day with no liabilities rather than as one not recorded.
+ */
+ ownLiabilities?: number;
+ crrRequired: number;
+ crrMaintained: number;
+ /** Negative where the day fell short. */
+ crrSurplus: number;
+ slrRequired: number;
+ slrMaintained: number;
+ slrSurplus: number;
+}
+
+/**
+ * The day-by-day position behind the form's two columns.
+ *
+ * Both ratios are reckoned over the period, not on the closes alone, so a form whose columns pass
+ * can still sit on days that did not. The short-day counts say so outright rather than leaving them
+ * to be spotted in a table of thirty rows.
+ */
+export interface Form1Daily {
+ from: string;
+ to: string;
+ days: Form1DailyRow[];
+ dayCount: number;
+ crrAverage: number;
+ slrAverage: number;
+ crrLowest: number;
+ crrLowestOn: string | null;
+ slrLowest: number;
+ slrLowestOn: string | null;
+ crrShortDays: number;
+ slrShortDays: number;
+}
+
+export interface Form1Statutory {
+ formCode: string;
+ formName: string;
+ statutoryReference: string;
+ reportingDate: string;
+ /** The fortnight closes the form reports, in time order. */
+ columns: string[];
+ /**
+ * The date each column's ratios are charged on, in the same order.
+ *
+ * Absent on a return saved before the base stopped being a column of its own, so the screen reads
+ * it defensively rather than assuming every snapshot carries it.
+ */
+ baseColumns?: string[];
+ /** Every day of the period, with what each ratio required and what was held. */
+ daily?: Form1Daily;
+ crrPercentage: number | null;
+ slrPercentage: number | null;
+ partA: Form1Part;
+ /**
+ * Part B settles section 18 — the cash reserve. Absent on a return that settles only section 24,
+ * where printing it would put a CRR compliance statement on an SLR form.
+ */
+ partB?: Form1Part;
+ partC: Form1Part;
+ unmetRequirements: string[];
+}
+
+/** One row of the Memorandum or an Annexure — a stated figure, entering no total. */
+export interface SectionRow {
+ label: string;
+ configKey: string;
+ configured: boolean;
+ amount: number | null;
+ accounts: { glAccountId: number; glCode: string; name: string; balance: number }[];
+}
+
+/**
+ * Compliance across the whole fortnight.
+ *
+ * CRR is not met by the closing day's balance: the full requirement must be held on average over the
+ * fortnight AND a daily minimum on every single day of it, so a bank that ran empty for thirteen days
+ * and topped up on the last one fails on the daily test while passing on the closing position.
+ */
+export interface FortnightMaintenance {
+ /** Each day of the month, with the requirement it was measured against and the gap. */
+ days: { date: string; required: number; amount: number; surplus: number }[];
+ /** The month the table covers. */
+ periodStart?: string;
+ periodEnd?: string;
+ periodLabel?: string;
+ dayCount: number;
+ average: number;
+ lowest: number;
+ lowestOn: string | null;
+ /** False while the fortnight is still running, so the average is of the days elapsed. */
+ fortnightComplete: boolean;
+ requiredAverage: number;
+ dailyMinimumPercent: number;
+ dailyMinimumAmount: number;
+ averageMet: boolean;
+ dailyMinimumMet: boolean;
+ averageShortfall: number;
+}
+
+/** The whole return, as the backend computes it. */
+export interface ReserveReturn {
+ formCode: string;
+ formName: string;
+ frequency: string;
+ reportingDate: string;
+ rateType: string;
+ ratePercentage: number | null;
+ /** True while the rate is still the placeholder shipped with the installation. */
+ rateIsSeedDefault: boolean;
+ basis: {
+ lagDays?: number;
+ rawLagDate?: string;
+ /** The nominal reporting Friday, before any shift off a holiday. */
+ reportingFriday?: string;
+ dtlAsOnDate: string;
+ holdingsAsOnDate: string;
+ /** True when that Friday was a holiday and the position was taken a working day earlier. */
+ shiftedForHoliday?: boolean;
+ note: string;
+ };
+ lines: ReserveLine[];
+ /** The two dates the return is struck at, so the table can head its columns with them. */
+ baseDate?: string;
+ currentDate?: string;
+ /** Form A's Memorandum and its two Annexures — sections of the return, not lines on it. */
+ memorandum?: SectionRow[];
+ annexureA?: SectionRow[];
+ annexureB?: SectionRow[];
+ /** Compliance across the fortnight rather than on the reporting date alone. Form 1 only. */
+ maintenance?: FortnightMaintenance;
+ computable: boolean;
+ dtlAmount?: number;
+ requiredAmount?: number;
+ maintainedAmount?: number;
+ surplusOrShortfall?: number;
+ maintainedPercentage?: number | null;
+ compliant?: boolean;
+ unmetRequirements: string[];
+ // Form 1 only.
+ fortnight?: number;
+ fortnightLabel?: string;
+ fortnightStart?: string;
+ fortnightEnd?: string;
+ // Form 9 only.
+ requestedDate?: string;
+ monthStart?: string;
+}
+
+/**
+ * Shared behaviour for the two reserve returns — Form 1 (CRR) and Form 9 (SLR).
+ *
+ * Both are the same statement: a percentage of Demand and Time Liabilities must be maintained in a
+ * defined class of assets, and the return compares what is required against what is held. They
+ * differ only in which figures make up the maintained side and on which dates each side is read,
+ * so everything except the fetch and the Excel layout lives here.
+ *
+ * Nothing is computed in the browser. The figures come from the ledger through one endpoint that
+ * already aggregates them; re-deriving totals here would produce a second set of numbers that could
+ * disagree with the filed return.
+ */
+export abstract class ReserveReturnBase {
+ protected rbiService = inject(RbiCircularsService);
+
+ /** Which return to fetch. */
+ protected abstract fetch(reportingDate: string): Observable;
+
+ /** Filename stem for the Excel export. */
+ protected abstract readonly exportStem: string;
+
+ /** Which return this screen is, so saved returns list only this one's. */
+ protected abstract readonly formCode: string;
+
+ data: ReserveReturn | null = null;
+ loading = true;
+ errorMsg = '';
+ generating = false;
+
+ /** The reporting date the user is looking at. Editable, so past periods can be reviewed. */
+ reportingDate = new Date().toISOString().slice(0, 10);
+
+ /**
+ * The date the liability base is struck at, given outright.
+ *
+ * The 45-day lag lands where it lands, and a bank reading an earlier fortnight — or correcting a
+ * filing — needs to name the date rather than work backwards from it. Left blank it is derived
+ * from the lag as before, and the return says on its face which of the two it used.
+ *
+ * Form 9 ignores it: there both sides are struck at the month end, so there is no second date to
+ * choose.
+ */
+ baseDate = '';
+
+ /** Whether this return has a base date distinct from its reporting date. Form 9 does not. */
+ readonly hasBaseDate: boolean = false;
+
+ /**
+ * Nothing is fetched until a date is chosen.
+ *
+ * The return is dated: which fortnight it covers, which lagged date the base is read at and which
+ * rate is in force all follow from it. Opening straight onto today's figures invites a filer to
+ * read a return for a period they never asked for, so the screen asks first.
+ */
+ awaitingDate = true;
+
+ /**
+ * What the prompt calls this return, before any data has been fetched to name it. Once the return
+ * runs its own `formName` is used instead.
+ */
+ get promptTitle(): string {
+ return this.formCode === 'FORM_9' ? 'Form 9 — Statutory Liquidity Ratio' : 'Form 1 — Cash Reserve Ratio';
+ }
+
+ /** Runs the return for the chosen date. */
+ generate(): void {
+ if (!this.reportingDate) {
+ this.errorMsg = 'Choose a reporting date first.';
+ return;
+ }
+ // `max` on the input stops the picker, not a typed or pasted date, and a future one returns
+ // today's figures under a heading that has not happened rather than failing.
+ if (this.reportingDate > this.today) {
+ this.errorMsg = `The reporting date (${this.reportingDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ if (this.baseDate && this.baseDate > this.today) {
+ this.errorMsg = `The base date (${this.baseDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ this.awaitingDate = false;
+ this.load();
+ }
+
+ /**
+ * Back to the date prompt, so a filer can run a different period. Both dates are kept as entered:
+ * moving from one fortnight to the next usually means editing one field, not retyping both.
+ */
+ changeDate(): void {
+ this.awaitingDate = true;
+ this.data = null;
+ this.errorMsg = '';
+ }
+
+ /**
+ * The fortnight the chosen date falls in, shown beside the picker so a filer sees which period
+ * they are about to run. Fortnights close on the 15th and on the month end.
+ */
+ get chosenFortnight(): string {
+ if (!this.reportingDate) {
+ return '';
+ }
+ const d = new Date(this.reportingDate + 'T00:00:00');
+ const first = d.getDate() <= 15;
+ const end = first ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ const month = d.toLocaleDateString('en-IN', { month: 'short', year: 'numeric' });
+ return `${first ? 1 : 16}–${end} ${month}`;
+ }
+
+ /** Jumps the picker to a fortnight close, which is what a filed return is usually dated. */
+ setToFortnightClose(which: 'first' | 'second'): void {
+ const d = this.reportingDate ? new Date(this.reportingDate + 'T00:00:00') : new Date();
+ const day = which === 'first' ? 15 : new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
+ this.reportingDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
+ }
+
+ toastMsg = '';
+ toastVisible = false;
+ private toastTimer: any;
+
+ /** Returns saved earlier, listed under the live one. */
+ savedReturns: any[] = [];
+ savedLoading = false;
+ saving = false;
+ savedRemarks = '';
+
+ /**
+ * Which half of the screen is showing: the return itself, or the ones kept on record.
+ *
+ * Two tabs rather than one long page: the live return and the saved list are read at different
+ * times and for different reasons, and stacking them meant scrolling past the whole statement -
+ * including a month of daily rows - to reach the list at all.
+ */
+ activeTab: 'report' | 'statutory' | 'saved' = 'report';
+
+ showTab(tab: 'report' | 'statutory' | 'saved'): void {
+ this.activeTab = tab;
+ }
+
+ /**
+ * Whether this screen also offers the statutory Form 1 — the return as a co-operative bank files
+ * it, three dated columns with both ratios settled on one base. Only Form 1 does; Form 9 has no
+ * statutory counterpart in these rules, so its subclass leaves this false.
+ */
+ readonly hasStatutoryForm: boolean = false;
+
+ /**
+ * Whether the statutory form asks for two fortnight closes or one reporting date.
+ *
+ * Form 1 is filed fortnightly and reports two closes side by side, so a filer names both. Form 9
+ * is filed monthly: the month it covers settles which columns it prints, and asking for two dates
+ * there would let a filer name a pair that is not a month.
+ */
+ readonly statutoryTakesTwoColumns: boolean = true;
+
+ /** What the statutory form is called on its tab, so the two returns cannot be confused. */
+ readonly statutoryTabLabel: string = 'Form 1';
+
+ /** What the statutory form is called on its own prompt and button. */
+ readonly statutoryTitle: string = 'Form 1 — Sections 18(1) and 24(3)';
+ readonly statutorySubtitle: string =
+ 'Banking Regulation (Co-operative Societies) Rules, 1966. The form reports two fortnight closes ' +
+ 'side by side — for September that is 15/09 and 30/09 — and charges both ratios on the matching ' +
+ 'close one month earlier.';
+
+ // ── Statutory form state ──
+ //
+ // Held here rather than on Form 1 because Form 9 now carries a statutory form too, and the
+ // template they share reads these directly.
+
+ /** The statutory return, fetched only when its tab is first opened. */
+ statutory: Form1Statutory | null = null;
+ statutoryLoading = false;
+ statutoryError = '';
+
+ /**
+ * Nothing is fetched for the statutory form until a date is chosen. Which period it covers, which
+ * columns it prints and which base date the ratios are charged on all follow from that one field.
+ */
+ statutoryAwaitingDate = true;
+ statutoryDate = new Date().toISOString().slice(0, 10);
+
+ /** The two fortnight closes the form's first two columns report. Unused on a monthly return. */
+ statutoryFirstColumn = '';
+ statutorySecondColumn = '';
+
+ /**
+ * The base date each column's ratios are charged on.
+ *
+ * Filled in from the rule as soon as a close is entered — the last Friday of that close's second
+ * preceding fortnight — and editable from there. Shown rather than hidden because every required
+ * amount on the form is a percentage of the liabilities on this date, and a reviewer cannot check
+ * a requirement whose base they cannot see. Editable because a bank that migrated part-way
+ * through a period has a base the rule produces and the ledger cannot answer.
+ */
+ statutoryFirstBase = '';
+ statutorySecondBase = '';
+
+ /**
+ * The base date a close is charged on: the previous month's matching fortnight close.
+ *
+ * Which fortnight the column falls in settles it, not the day it carries. A date in the first
+ * half — the 1st to the 15th — charges on the 15th of the previous month; anything later charges
+ * on the previous month's END, whatever day that is. So 30/09 charges on 31/08, and so does
+ * 20/09: both sit in the second fortnight, and the second fortnight's close is the month end.
+ *
+ * Keyed on the fortnight rather than on the day number because the two closes are not the same
+ * kind of date. The 15th is a fixed day; the month end moves between 28 and 31. Matching day for
+ * day would charge 30/09 on 30/08, a day that closes nothing.
+ *
+ * RBI's own wording is "the last Friday of the second preceding fortnight", and that rule was
+ * tried here. It was taken out again because the dates it produced did not match the returns this
+ * bank has already filed by hand — it puts the base on a weekday that moves and roughly six weeks
+ * back. The fortnight closes this bank actually files on are the 15th and the month end.
+ *
+ * Mirrors the backend, which computes the same date for the figures. Kept here so the prompt can
+ * fill the base in as the close is typed; a base entered by hand still overrides it.
+ */
+ baseDateFor(closeDate: string): string {
+ if (!closeDate) {
+ return '';
+ }
+ const d = new Date(closeDate + 'T00:00:00');
+ if (Number.isNaN(d.getTime())) {
+ return '';
+ }
+ // A date input hands over whatever has been typed so far, and a half-typed year parses as a
+ // real date: "0026-09-30" is year 26, which the Date constructor then reads as 1926, because
+ // it adds 1900 to any year from 0 to 99. The base silently came back a century out. Nothing
+ // before 1900 is a date this form has any use for, so it is treated as still being typed.
+ if (d.getFullYear() < 1900) {
+ return '';
+ }
+ // The previous month, reached by taking day 0 of this one: month is zero-based, so this lands
+ // on the last day of the month before.
+ const previousMonthEnd = new Date(d.getFullYear(), d.getMonth(), 0);
+ if (d.getDate() <= 15) {
+ return this.asIsoDate(new Date(previousMonthEnd.getFullYear(), previousMonthEnd.getMonth(), 15));
+ }
+ return this.asIsoDate(previousMonthEnd);
+ }
+
+ private asIsoDate(d: Date): string {
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+ }
+
+ /** Fills a column's base from the rule, leaving one the filer has already set alone. */
+ onStatutoryColumnChange(which: 'first' | 'second'): void {
+ if (which === 'first') {
+ this.statutoryFirstBase = this.baseDateFor(this.statutoryFirstColumn);
+ } else {
+ this.statutorySecondBase = this.baseDateFor(this.statutorySecondColumn);
+ }
+ }
+
+ /** Fetches the statutory form. Overridden by each return that has one. */
+ protected fetchStatutory(): Observable {
+ return of(null);
+ }
+
+ /** Runs the statutory return for the dates entered. */
+ generateStatutory(): void {
+ if (this.statutoryTakesTwoColumns) {
+ if (!this.statutoryFirstColumn || !this.statutorySecondColumn) {
+ this.statutoryError = 'Enter both fortnight closing dates — for September that is 15/09 and 30/09.';
+ return;
+ }
+ // A half-typed year parses as a real date — "30-09-0026" is year 26 — and then fails the
+ // ordering check below with a message about column order, which is not what went wrong.
+ // Caught first so the filer is told about the year rather than sent looking at the dates.
+ const halfTyped = [
+ this.statutoryFirstColumn,
+ this.statutorySecondColumn,
+ this.statutoryFirstBase,
+ this.statutorySecondBase
+ ].filter((d) => d && Number(d.slice(0, 4)) < 1900);
+ if (halfTyped.length) {
+ this.statutoryError = `The year in ${halfTyped[0]} looks incomplete — enter all four digits.`;
+ return;
+ }
+ if (this.statutoryFirstColumn >= this.statutorySecondColumn) {
+ // The form reads left to right as the period advances. Reversed, every movement on it would
+ // carry the wrong sign while still looking like a properly filled return.
+ this.statutoryError = 'The first column must fall before the second.';
+ return;
+ }
+ if (this.statutorySecondColumn > this.today) {
+ this.statutoryError = `The second column (${this.statutorySecondColumn}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ // A base dated after the close it serves would charge the ratio on liabilities the bank had
+ // not taken on yet — arithmetically fine, and meaningless.
+ if (this.statutoryFirstBase && this.statutoryFirstBase > this.statutoryFirstColumn) {
+ this.statutoryError = `The first column's base date (${this.statutoryFirstBase}) falls after the close it is charged on.`;
+ return;
+ }
+ if (this.statutorySecondBase && this.statutorySecondBase > this.statutorySecondColumn) {
+ this.statutoryError = `The second column's base date (${this.statutorySecondBase}) falls after the close it is charged on.`;
+ return;
+ }
+ // The return is made up to the later of the two closes, so the reporting date follows from
+ // them rather than being a third date a filer has to keep consistent with the first two.
+ this.statutoryDate = this.statutorySecondColumn;
+ } else {
+ if (!this.statutoryDate) {
+ this.statutoryError = 'Choose a reporting date first.';
+ return;
+ }
+ if (this.statutoryDate > this.today) {
+ this.statutoryError = `The reporting date (${this.statutoryDate}) has not happened yet — today is ${this.today}.`;
+ return;
+ }
+ }
+
+ this.statutoryAwaitingDate = false;
+ this.statutoryLoading = true;
+ this.statutoryError = '';
+ this.fetchStatutory()
+ .pipe(
+ catchError((err: any) => {
+ this.statutoryError =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute the statutory return from the ledger.';
+ return of(null);
+ })
+ )
+ .subscribe((res: Form1Statutory | null) => {
+ this.statutoryLoading = false;
+ this.statutory = res;
+ });
+ }
+
+ /** Back to the date prompt, so a filer can run a different period. */
+ changeStatutoryDate(): void {
+ this.statutoryAwaitingDate = true;
+ this.statutory = null;
+ this.statutoryError = '';
+ }
+
+ /** All three parts in printing order, so the template walks one list. */
+ get statutoryParts(): Form1Part[] {
+ if (!this.statutory) {
+ return [];
+ }
+ // partB is absent on a return that settles only section 24, so it is skipped rather than
+ // printed empty.
+ return [
+ this.statutory.partA,
+ this.statutory.partB,
+ this.statutory.partC
+ ].filter((p): p is Form1Part => !!p);
+ }
+
+ /** A shortfall reads as one rather than as a number with a minus sign. */
+ isShort(value: number): boolean {
+ return value < 0;
+ }
+
+ /** The saved return currently open below the list, or null when none is. */
+ viewingSaved: any = null;
+ /**
+ * The opened snapshot's own statement, rendered in its own section. Held separately from `data` so
+ * the live return stays on screen alongside it and the two can be compared directly.
+ */
+ savedData: ReserveReturn | null = null;
+
+ load(): void {
+ this.loading = true;
+ this.errorMsg = '';
+ this.fetch(this.reportingDate)
+ .pipe(
+ catchError((err: any) => {
+ this.errorMsg =
+ err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not compute this return from the ledger.';
+ return of(null);
+ })
+ )
+ .subscribe((res: ReserveReturn | null) => {
+ this.loading = false;
+ this.data = res;
+ });
+ }
+
+ onDateChange(): void {
+ this.load();
+ }
+
+ /** Jump to the period containing today. */
+ resetToToday(): void {
+ this.reportingDate = new Date().toISOString().slice(0, 10);
+ this.load();
+ }
+
+ // ── presentation helpers ──
+
+ /**
+ * The titled sections Form A carries alongside the main statement, in the order the format states
+ * them. Each is skipped by the template when it has no rows, so Form 9 — which has none — is
+ * unaffected.
+ */
+ get namedSections(): { title: string; hint: string; rows: SectionRow[] }[] {
+ if (!this.data) {
+ return [];
+ }
+ return [
+ {
+ title: 'Memorandum to Form A',
+ hint: 'Stated alongside the return. These figures are reported, not added into the CRR requirement.',
+ rows: this.data.memorandum ?? []
+ },
+ {
+ title: 'Annexure A — Foreign currency liabilities and assets',
+ hint: 'Reported in INR equivalent at the applicable reference rate. Nil where the bank has no foreign-currency business.',
+ rows: this.data.annexureA ?? []
+ },
+ {
+ title: 'Annexure B — Investment details',
+ hint: 'Investments split between approved and non-approved securities, shares, debentures and bonds.',
+ rows: this.data.annexureB ?? []
+ }
+ ];
+ }
+
+ get isShortfall(): boolean {
+ return !!this.data?.computable && this.data.compliant === false;
+ }
+
+ /** Magnitude of the gap, so the sign lives in the label rather than in the number. */
+ get shortfallAmount(): number {
+ return Math.abs(this.data?.surplusOrShortfall ?? 0);
+ }
+
+ // ── Saved returns ──
+
+ loadSaved(): void {
+ this.savedLoading = true;
+ // Returns saved under a code this screen no longer writes are still returns this screen filed.
+ // Form 1 moved from the working report's code to the statutory one, and fetching only the
+ // current code would hide every return saved before that — which is exactly the history a
+ // saved list exists to hold.
+ const codes = [
+ this.formCode,
+ ...this.legacyFormCodes
+ ];
+ forkJoin(codes.map((code) => this.rbiService.getSavedReturns(code).pipe(catchError(() => of([]))))).subscribe(
+ (results: any[][]) => {
+ this.savedLoading = false;
+ this.savedReturns = results
+ .flat()
+ .filter((row) => row)
+ .sort((a, b) => String(b.reportingDate ?? '').localeCompare(String(a.reportingDate ?? '')));
+ }
+ );
+ }
+
+ /**
+ * Codes this screen used to save under. Listed alongside the current one so nothing already
+ * filed drops out of the list when a form code changes.
+ */
+ protected readonly legacyFormCodes: string[] = [];
+
+ /** Keeps the return on screen, so it can be read again later exactly as it stands now. */
+ saveCurrent(): void {
+ // What is on screen is what gets saved. On a return whose statutory form IS the return, the
+ // working report is never fetched, so saving `data` would save nothing at all — and the return
+ // the filer was looking at could not be kept.
+ const snapshot = this.saveableReturn();
+ if (!snapshot) {
+ return;
+ }
+ this.saving = true;
+ this.rbiService
+ .saveReturn(snapshot.formCode, snapshot.reportingDate, snapshot.report, this.savedRemarks || undefined)
+ .pipe(
+ catchError((err: any) => {
+ this.saving = false;
+ this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not save this return.');
+ return of(null);
+ })
+ )
+ .subscribe((rows: any[] | null) => {
+ this.saving = false;
+ if (!rows) {
+ return;
+ }
+ this.savedReturns = rows;
+ this.savedRemarks = '';
+ this.toast('Return saved.');
+ });
+ }
+
+ /**
+ * Opens a saved return in its own section below the list, leaving the live return on screen.
+ *
+ * Deliberately not in place of the live one: the reason to open a saved return is almost always to
+ * compare it against where the bank stands now, and swapping the live figures out makes that
+ * comparison impossible without clicking back and forth from memory.
+ */
+ viewSaved(row: any): void {
+ // Clicking the open one closes it, so the button toggles rather than re-fetching.
+ if (this.viewingSaved?.id === row.id) {
+ this.closeSaved();
+ return;
+ }
+ this.rbiService
+ .getSavedReturn(row.id)
+ .pipe(
+ catchError(() => {
+ this.toast('Could not open this saved return.');
+ return of(null);
+ })
+ )
+ .subscribe((saved: any) => {
+ if (!saved?.report) {
+ this.toast('This saved return has no stored statement.');
+ return;
+ }
+ this.viewingSaved = saved;
+ this.savedData = saved.report as ReserveReturn;
+ // Opens on the Saved tab, where the list it was chosen from is - never jumping the user to
+ // the live return, which is a different statement.
+ this.activeTab = 'saved';
+ setTimeout(() => document.getElementById('rr-saved-view')?.scrollIntoView({ behavior: 'smooth' }), 0);
+ });
+ }
+
+ /** Closes the opened snapshot. The live return was never hidden, so nothing is restored. */
+ closeSaved(): void {
+ this.viewingSaved = null;
+ this.savedData = null;
+ }
+
+ /** Kept for callers that still name the old action. */
+ backToLive(): void {
+ this.closeSaved();
+ }
+
+ /** Magnitude of the opened snapshot's gap, so the sign lives in its label rather than the number. */
+ get savedSurplus(): number {
+ return Math.abs(this.savedData?.surplusOrShortfall ?? 0);
+ }
+
+ updateSavedStatus(row: any, status: string): void {
+ this.rbiService
+ .updateSavedReturnStatus(row.id, status)
+ .pipe(
+ catchError((err: any) => {
+ this.toast(err?.error?.errors?.[0]?.defaultUserMessage ?? 'Could not update the status.');
+ return of(null);
+ })
+ )
+ .subscribe((rows: any[] | null) => {
+ if (!rows) {
+ return;
+ }
+ this.savedReturns = rows;
+ if (this.viewingSaved?.id === row.id) {
+ this.viewingSaved = { ...this.viewingSaved, status };
+ }
+ this.toast(`Marked ${this.statusLabel(status)}.`);
+ });
+ }
+
+ deleteSaved(row: any): void {
+ this.rbiService
+ .deleteSavedReturn(row.id)
+ .pipe(
+ catchError(() => {
+ this.toast('Could not remove this saved return.');
+ return of(null);
+ })
+ )
+ .subscribe((rows: any[] | null) => {
+ if (!rows) {
+ return;
+ }
+ this.savedReturns = rows;
+ if (this.viewingSaved?.id === row.id) {
+ this.backToLive();
+ }
+ this.toast('Saved return removed.');
+ });
+ }
+
+ /** The stages a return moves through, in order, for the status menu. */
+ readonly filingStatuses = [
+ 'DRAFT',
+ 'GENERATED',
+ 'CHECKED',
+ 'APPROVED',
+ 'PROVISIONAL_SUBMITTED',
+ 'FINAL_SUBMITTED',
+ 'REVISED'
+ ];
+
+ statusLabel(status: string): string {
+ const labels: Record = {
+ DRAFT: 'Draft',
+ GENERATED: 'Generated',
+ CHECKED: 'Checked',
+ APPROVED: 'Approved',
+ PROVISIONAL_SUBMITTED: 'Provisional submitted',
+ FINAL_SUBMITTED: 'Final submitted',
+ REVISED: 'Revised'
+ };
+ return labels[status] ?? status;
+ }
+
+ /**
+ * The return currently on screen, in the shape the saved list stores.
+ *
+ * Overridden where the screen shows something other than the working report, so that Save keeps
+ * the statement the filer was actually reading rather than one they never saw.
+ */
+ protected saveableReturn(): { formCode: string; reportingDate: string; report: any } | null {
+ return this.data
+ ? { formCode: this.data.formCode, reportingDate: this.data.reportingDate, report: this.data }
+ : null;
+ }
+
+ /** Whether there is anything on screen to save — drives the Save button. */
+ get canSave(): boolean {
+ return this.saveableReturn() !== null;
+ }
+
+ /**
+ * Today, as a date input formats it — the latest date any of these returns may be run for.
+ *
+ * A balance is summed as everything up to a date, so a future one does not fail and does not come
+ * back empty: it returns today's figures under a heading that has not happened, and a period
+ * still running reads as one that has closed.
+ */
+ readonly today = new Date().toISOString().slice(0, 10);
+
+ /**
+ * Whether the opened snapshot is a statutory form rather than a working report.
+ *
+ * The two are stored in the same list but are different statements: the working report carries a
+ * `basis` block and one amount per line, the statutory form carries three dated columns and no
+ * basis at all. Rendering one through the other's markup reads every missing field as blank, so
+ * the shape is checked before anything is drawn.
+ */
+ get savedIsStatutory(): boolean {
+ return (this.savedData as any)?.formCode === 'FORM_1_STATUTORY';
+ }
+
+ /** The opened snapshot as a statutory form, for the template to walk its parts. */
+ get savedStatutory(): Form1Statutory | null {
+ return this.savedIsStatutory ? (this.savedData as any as Form1Statutory) : null;
+ }
+
+ /** The opened statutory snapshot's parts, in printing order. */
+ get savedStatutoryParts(): Form1Part[] {
+ const s = this.savedStatutory;
+ // partB is absent on a snapshot of a return that settles only section 24, so it is skipped
+ // rather than rendered as an empty part with a heading and no lines.
+ return s ? [
+ s.partA,
+ s.partB,
+ s.partC
+ ].filter((p): p is Form1Part => !!p) : [];
+ }
+
+ /**
+ * True when a head takes only part of an account, so the printed figure is not that account's
+ * balance. Anything at or above 100, or unset, is the whole balance and needs no explanation.
+ */
+ isPartial(account: ReserveLineAccount): boolean {
+ const weight = account.weightPercentage;
+ return weight !== null && weight !== undefined && weight < 100 && account.accountBalance != null;
+ }
+
+ /**
+ * The balance a weighted account's share is taken of, stated for both of the return's dates
+ * where they differ.
+ *
+ * A weight applies to a different balance in each column, and printing only one of them makes
+ * the other column's figure look as though it does not follow from the share stated beside it.
+ * Where the account held the same balance on both dates — or is only on the return at one of
+ * them — one figure says everything there is to say.
+ */
+ portionOf(account: ReserveLineAccount): string {
+ const base = account.baseAccountBalance;
+ const current = account.currentAccountBalance;
+ if (base === undefined && current === undefined) {
+ return this.amount(account.accountBalance);
+ }
+ if (base == null || current == null) {
+ return this.amount(base ?? current ?? account.accountBalance);
+ }
+ if (base === current) {
+ return this.amount(base);
+ }
+ return `${this.amount(base)} / ${this.amount(current)}`;
+ }
+
+ amount(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ return value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
+ }
+
+ percent(value: number | null | undefined): string {
+ if (value === null || value === undefined) {
+ return '—';
+ }
+ return `${value.toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 4 })}%`;
+ }
+
+ protected toast(msg: string): void {
+ this.toastMsg = msg;
+ this.toastVisible = true;
+ clearTimeout(this.toastTimer);
+ this.toastTimer = setTimeout(() => (this.toastVisible = false), 3200);
+ }
+
+ // ── Excel export ──
+
+ /**
+ * Exports the return as filed: the same figures the screen shows, plus the basis dates and the
+ * account-level breakdown, so a reviewer can trace any total back to the accounts behind it.
+ */
+ async exportWorkbook(): Promise {
+ if (!this.data) {
+ return;
+ }
+ this.generating = true;
+ try {
+ const wb = newWorkbook();
+ const sheet = wb.addWorksheet(this.data.formCode.replace('_', ' '));
+ // Five columns because the daily table is the widest block on the sheet; the statement above
+ // it uses the first three and leaves the rest empty.
+ sheet.columns = [
+ { width: 52 },
+ { width: 22 },
+ { width: 20 },
+ { width: 22 },
+ { width: 16 }];
+
+ addBanner(sheet, `${this.data.formName} — ${this.data.frequency}`, 'C');
+
+ let start = sheet.rowCount + 1;
+ this.addKeyValue(sheet, 'Reporting date', this.data.reportingDate);
+ if (this.data.fortnightLabel) {
+ this.addKeyValue(
+ sheet,
+ 'Fortnight',
+ `${this.data.fortnightLabel} (${this.data.fortnightStart} to ${this.data.fortnightEnd})`
+ );
+ }
+ this.addKeyValue(sheet, 'DTL base as at', this.data.basis.dtlAsOnDate);
+ this.addKeyValue(sheet, 'Balances maintained as at', this.data.basis.holdingsAsOnDate);
+ if (this.data.basis.rawLagDate) {
+ this.addKeyValue(sheet, `Lag (${this.data.basis.lagDays} days) falls on`, this.data.basis.rawLagDate);
+ }
+ this.addKeyValue(
+ sheet,
+ `${this.data.rateType} rate applied`,
+ this.data.ratePercentage === null
+ ? 'Not set'
+ : `${this.data.ratePercentage}%${this.data.rateIsSeedDefault ? ' (placeholder — not confirmed)' : ''}`
+ );
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+ sheet.addRow([]);
+
+ // Basis note, spelled out so the export is self-explanatory away from the screen.
+ const noteRow = sheet.addRow([this.data.basis.note]);
+ sheet.mergeCells(noteRow.number, 1, noteRow.number, 3);
+ noteRow.alignment = { wrapText: true };
+ noteRow.font = { size: 9, italic: true };
+ sheet.addRow([]);
+
+ // Figures.
+ start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ 'Particulars',
+ 'As at',
+ 'Amount (INR)'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ for (const line of this.data.lines) {
+ const row = sheet.addRow([
+ line.label,
+ line.asOnDate,
+ line.configured ? line.amount : 'Not configured'
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ row.getCell(3).numFmt = '#,##0.00';
+ row.getCell(3).alignment = { horizontal: 'right' };
+
+ // Account-level breakdown, indented under its line. An excluded account is named with its
+ // reason rather than dropped, so the workbook cannot read as complete when the figure is
+ // short by it — the file is what gets filed, so the caveat has to travel in it.
+ for (const account of line.accounts) {
+ // A partial share is named on the row too: without it the figure reads as the account's
+ // own balance and cannot be reconciled back to the ledger.
+ const portion = this.isPartial(account)
+ ? ` (${account.weightPercentage}% of ${this.amount(account.accountBalance)})`
+ : '';
+ const sub = sheet.addRow([
+ ` ${account.glCode} — ${account.name}${portion}${account.excluded ? ` (excluded — ${account.excluded})` : ''}`,
+ account.liabilityClass ?? '',
+ account.excluded ? 'Excluded' : account.balance
+ ]);
+ sub.font = { size: 9, color: { argb: account.excluded ? 'FFB06000' : 'FF666666' } };
+ sub.getCell(3).numFmt = '#,##0.00';
+ sub.getCell(3).alignment = { horizontal: 'right' };
+ }
+ }
+
+ if (this.data.computable) {
+ const required = sheet.addRow([
+ `${this.data.rateType} required (${this.data.ratePercentage}% of DTL)`,
+ '',
+ this.data.requiredAmount ?? null
+ ]);
+ const maintained = sheet.addRow([
+ `${this.data.rateType} maintained`,
+ '',
+ this.data.maintainedAmount ?? null
+ ]);
+ const gap = sheet.addRow([
+ this.data.compliant ? 'Surplus' : 'Shortfall',
+ '',
+ this.data.surplusOrShortfall ?? null
+ ]);
+ for (const row of [
+ required,
+ maintained,
+ gap
+ ]) {
+ row.font = { bold: true };
+ row.getCell(3).numFmt = '#,##0.00';
+ row.getCell(3).alignment = { horizontal: 'right' };
+ row.getCell(3).fill = solidFill(XL.derivedBlue);
+ }
+ }
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+
+ // Fortnight maintenance, which is what CRR compliance actually turns on. A workbook showing
+ // only the closing position would let a fortnight that breached the daily floor look clean.
+ if (this.data.maintenance) {
+ const m = this.data.maintenance;
+ sheet.addRow([]);
+ const head = sheet.addRow([
+ 'Maintenance over the fortnight',
+ '',
+ ''
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ const start = sheet.rowCount + 1;
+ this.addKeyValue(sheet, 'Average maintained', this.amount(m.average));
+ this.addKeyValue(sheet, 'Required average', this.amount(m.requiredAverage));
+ this.addKeyValue(sheet, 'Average test', m.averageMet ? 'Met' : 'NOT MET');
+ this.addKeyValue(
+ sheet,
+ `Daily floor (${m.dailyMinimumPercent}% of requirement)`,
+ this.amount(m.dailyMinimumAmount)
+ );
+ this.addKeyValue(sheet, 'Lowest day', `${this.amount(m.lowest)} on ${m.lowestOn}`);
+ this.addKeyValue(sheet, 'Daily minimum test', m.dailyMinimumMet ? 'Met' : 'NOT MET');
+ this.addKeyValue(
+ sheet,
+ 'Days counted',
+ `${m.dayCount}${m.fortnightComplete ? ' (full fortnight)' : ' (to reporting date)'}`
+ );
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+
+ sheet.addRow([]);
+ const dayHead = sheet.addRow([
+ 'Date',
+ 'Required (INR)',
+ 'Actual (INR)',
+ 'Surplus / (Shortfall)',
+ 'Against floor'
+ ]);
+ dayHead.font = { bold: true };
+ dayHead.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ const dayStart = sheet.rowCount;
+ for (const day of m.days) {
+ const row = sheet.addRow([
+ day.date,
+ day.required,
+ day.amount,
+ day.surplus,
+ day.amount < m.dailyMinimumAmount ? 'Below floor' : 'OK'
+ ]);
+ for (const col of [
+ 2,
+ 3,
+ 4
+ ]) {
+ row.getCell(col).numFmt = '#,##0.00';
+ row.getCell(col).alignment = { horizontal: 'right' };
+ }
+ }
+ boxAllCells(sheet, dayStart, sheet.rowCount, 5);
+ }
+
+ // The Memorandum and Annexures, each under its own heading.
+ for (const section of this.namedSections) {
+ if (!section.rows.length) {
+ continue;
+ }
+ sheet.addRow([]);
+ const title = sheet.addRow([
+ section.title,
+ '',
+ ''
+ ]);
+ title.font = { bold: true };
+ title.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ const start = sheet.rowCount;
+ for (const row of section.rows) {
+ const excelRow = sheet.addRow([
+ row.label,
+ '',
+ row.configured ? row.amount : 'Not configured'
+ ]);
+ excelRow.getCell(1).alignment = { wrapText: true };
+ excelRow.getCell(3).numFmt = '#,##0.00';
+ excelRow.getCell(3).alignment = { horizontal: 'right' };
+ }
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+ }
+
+ // Caveats travel with the workbook, not only the screen: a return computed on the placeholder
+ // rate is arithmetically right and still must not be filed, and the file is what gets sent on.
+ if (this.data.unmetRequirements.length) {
+ sheet.addRow([]);
+ const prefix = this.data.computable ? 'Check before filing' : 'Not computed';
+ for (const reason of this.data.unmetRequirements) {
+ const row = sheet.addRow([`${prefix}: ${reason}`]);
+ sheet.mergeCells(row.number, 1, row.number, 3);
+ row.font = { bold: true, size: 9 };
+ row.alignment = { wrapText: true };
+ }
+ }
+
+ await downloadWorkbook(wb, `${this.exportStem}_${this.data.reportingDate}.xlsx`);
+ this.toast('Workbook downloaded.');
+ } finally {
+ this.generating = false;
+ }
+ }
+
+ // Protected rather than private: a subclass exporting its own form — the statutory Form 1 — needs
+ // the same key/value block, and a second copy of it would drift from this one.
+ protected addKeyValue(sheet: ExcelJS.Worksheet, label: string, value: string): void {
+ const row = sheet.addRow([
+ label,
+ value
+ ]);
+ row.getCell(1).font = { bold: true };
+ row.getCell(1).fill = solidFill(XL.keyCell);
+ }
+
+ /**
+ * Prints the form on its own, in a window carrying nothing else.
+ *
+ * `window.print()` on the page printed blank. The form sits inside the app shell — fixed nav
+ * bars, a scrolling content pane — and the print engine lays that out from the top of the
+ * document, so the sheet itself fell past the end of the first page with the shell's own chrome
+ * occupying it. Component styles could not fix it either: they are scoped to this component and
+ * cannot reach the shell that surrounds it.
+ *
+ * So the sheet is lifted into a window of its own with the styles it needs inlined, which is what
+ * the passbook screens do for the same reason.
+ */
+ printReturn(): void {
+ const sheet = document.getElementById('f1s-printable');
+ if (!sheet) {
+ // Nothing generated yet — printing an empty screen would produce the blank page this exists
+ // to prevent.
+ return;
+ }
+
+ const win = window.open('', 'RbiStatutory', 'height=900,width=1200');
+ if (!win) {
+ // Blocked by the browser. Said out loud rather than returning quietly: a Print button that
+ // does nothing reads as the blank page this replaced.
+ this.toast('Allow pop-ups for this site to print the form.');
+ return;
+ }
+ const title = `${this.statutoryTabLabel} — ${this.statutory?.reportingDate ?? ''}`;
+ win.document.write(
+ `${title}` +
+ `${sheet.outerHTML}` +
+ '' +
+ ''
+ );
+ win.document.close();
+ win.focus();
+ }
+
+ /**
+ * The form's own styles, inlined because the print window loads no stylesheet of its own.
+ *
+ * Kept to what the form needs to read correctly on paper: the rules that carry meaning — the part
+ * banners, the totals, the column alignment — rather than a copy of the screen. Backgrounds are
+ * forced to print, because a part banner dropped to save ink takes the form's structure with it.
+ */
+ private static readonly PRINT_STYLES = `
+ @page { size: A4 landscape; margin: 12mm; }
+ * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
+ body { font-family: Roboto, Arial, sans-serif; color: #212121; margin: 0; }
+ .f1s-sheet { border: 1px solid #212121; padding: 16px; }
+ .f1s-sheet__head { text-align: center; border-bottom: 2px solid #212121; padding-bottom: 10px; margin-bottom: 12px; }
+ .f1s-sheet__title { font-weight: 700; font-size: 15px; text-transform: uppercase; }
+ .f1s-sheet__ref { font-size: 11px; color: #616161; margin-top: 3px; }
+ table { width: 100%; border-collapse: collapse; font-size: 11px; }
+ th, td { border: 1px solid #9e9e9e; padding: 5px 8px; vertical-align: top; }
+ th { background: #eee; font-weight: 700; font-size: 10.5px; text-align: center; border-color: #212121; }
+ .f1s-table__particulars { text-align: left; width: 52%; }
+ td.f1s-table__num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
+ .f1s-table__colnote { display: block; margin-top: 2px; font-weight: 400; font-size: 8.5px;
+ text-transform: uppercase; color: #616161; }
+ /* The form nests to three levels — (a), then (i), then the figure. Flattened, a line reads as
+ belonging to the wrong head and the sub-totals below stop making sense. */
+ .f1s-indent-1 { padding-left: 24px; }
+ .f1s-indent-2 { padding-left: 44px; }
+ .f1s-part td { background: #1b5e9e; color: #fff; font-weight: 700; border-color: #1b5e9e; }
+ .f1s-heading td { background: #f5f5f5; font-weight: 700; }
+ .f1s-total td { font-weight: 700; background: #fafafa; }
+ .f1s-total--result td { background: #e6f4ea; }
+ /* A shortfall reads as one rather than as a number with a minus sign — which on a filed form
+ is the difference between compliant and not. */
+ .f1s-neg { color: #c62828; }
+ .f1s-subnote td { font-size: 9.5px; font-style: italic; color: #616161; background: #fffdf5; }
+ /* The form is signed, so the space for it belongs on the print. */
+ .f1s-sign { margin-top: 48px; text-align: right; }
+ .f1s-sign__line { display: inline-block; border-top: 1px solid #212121; padding-top: 5px;
+ font-size: 11px; min-width: 210px; text-align: center; }
+ thead { display: table-header-group; }
+ tr { break-inside: avoid; }
+ /* An unmapped head reports nil, and a nil that nobody configured is a different thing from a
+ nil the bank actually holds. Kept on the printed form so a reviewer can tell them apart. */
+ .f1s-badge { display: inline-block; margin-left: 6px; padding: 0 4px; font-size: 8px;
+ font-weight: 700; text-transform: uppercase; color: #8a6100; background: #fff3cd;
+ border: 1px solid #e0c56b; border-radius: 3px; }
+ /* A form that is not ready to file says so on the paper too. Dropping this in print would hand
+ someone a statement that looks finished and carries a caveat only the screen ever showed. */
+ .f1s-alert { padding: 8px 12px; border: 1px solid #e0c56b; border-radius: 4px; font-size: 10px;
+ margin-bottom: 12px; background: #fff8e1; color: #8a6100; }
+ .f1s-unmet { margin: 6px 0 0 16px; padding: 0; }
+ /* Screen-only chrome that has no meaning on a filed form. */
+ .rr-noprint, .f1s-bar { display: none !important; }
+ `;
+
+ /**
+ * The statutory form as the workbook RBI files it in — the CIMS R377 template.
+ *
+ * Every figure comes from the return as generated, so the workbook follows whatever dates were
+ * asked for: change the closes and the whole of it moves with them. Nothing here is a figure
+ * typed into a template by hand.
+ */
+ async exportStatutory(): Promise {
+ if (!this.statutory) {
+ return;
+ }
+ this.generating = true;
+ try {
+ const wb = newWorkbook();
+ this.addCimsGeneralInformation(wb);
+ this.addCimsPartA(wb);
+ this.addCimsOthersSheet(wb);
+ this.addCimsPartBandC(wb);
+ this.addCimsPartD(wb);
+ this.addCimsSecuritiesSheet(wb, 'Part C.XII. c');
+ this.addCimsSecuritiesSheet(wb, 'Part D.XIV.e');
+ this.addCimsDailySheet(wb, 'CRR');
+ this.addCimsDailySheet(wb, 'SLR');
+ this.addCimsSignatory(wb);
+ // The working behind the two closes, after the template's own sheets. CIMS reads the sheets
+ // it knows by name and ignores the rest, so carrying it costs the filing nothing and saves
+ // producing it again when an inspector asks.
+ this.addDailySheet(wb);
+ await downloadWorkbook(wb, `R377_Form1_${this.statutory.reportingDate}`);
+ } finally {
+ this.generating = false;
+ }
+ }
+
+ // ── The CIMS R377 workbook ──
+ //
+ // RBI takes Form 1 through CIMS, which does not read a spreadsheet the way a person does: it
+ // looks for its own sheets, by its own names, carrying its own wording. A single sheet that read
+ // like the screen — which is what this used to export — is rejected before anyone looks at a
+ // figure in it. So the workbook below is the template's, line for line, and only the figures and
+ // the dates come from the return. Sheets this bank has nothing to put on (Part D is for scheduled
+ // banks) are still written, empty: CIMS checks that a sheet exists before it checks its rows.
+
+ /** The dated columns the return reports, which every sheet below is laid out across. */
+ private get cimsDates(): string[] {
+ return this.statutory?.columns ?? [];
+ }
+
+ private addCimsGeneralInformation(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('General Information');
+ sheet.columns = [
+ { width: 40 },
+ { width: 52 }];
+ addBanner(sheet, 'General Information', 'B');
+
+ const start = sheet.rowCount + 1;
+ // Return name, code and version are the template's own and are what CIMS matches on, so they
+ // are the one place in this workbook where a fixed string is the correct value.
+ this.addKeyValue(sheet, 'Return Name', 'New_Statement of Compliance with CRR and SLR (Form - I)');
+ this.addKeyValue(sheet, 'Return Code', 'R377');
+ // Blank where the bank's own particulars belong. They are filed-entity details this system does
+ // not hold, and a guess printed on a statutory return reads as a statement of fact.
+ this.addKeyValue(sheet, 'Bank Name', '');
+ this.addKeyValue(sheet, 'Bank Code', '');
+ this.addKeyValue(sheet, 'Address of Reporting Institution', '');
+ this.addKeyValue(sheet, 'RO Name', '');
+ this.addKeyValue(sheet, 'Reporting Frequency', 'Monthly');
+ this.addKeyValue(sheet, 'Month End Date', this.statutory?.reportingDate ?? '');
+ this.addKeyValue(sheet, 'Bank Type', '');
+ this.addKeyValue(sheet, 'Validation Status', '');
+ this.addKeyValue(sheet, 'Return Version', 'V1.0');
+ boxAllCells(sheet, start, sheet.rowCount, 2);
+
+ // Anything that would make the return wrong to file goes on the first sheet, where it is read
+ // before the figures rather than found after them.
+ if (this.statutory?.unmetRequirements?.length) {
+ sheet.addRow([]);
+ const head = sheet.addRow(['Check before filing:']);
+ head.font = { bold: true, color: { argb: 'FFB06000' } };
+ for (const reason of this.statutory.unmetRequirements) {
+ const row = sheet.addRow([reason]);
+ sheet.mergeCells(row.number, 1, row.number, 2);
+ row.alignment = { wrapText: true };
+ row.font = { size: 9, color: { argb: 'FFB06000' } };
+ }
+ }
+ }
+
+ /** Part A — the liabilities and assets, Roman-numbered I through VIII, across the dated columns. */
+ private addCimsPartA(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Part A');
+ sheet.columns = [
+ { width: 72 },
+ ...this.cimsDates.map(() => ({ width: 22 }))
+ ];
+ addBanner(sheet, 'Part A', this.cimsLastColumn());
+ sheet.addRow([]);
+ this.addCimsEndDates(sheet, 'End Date');
+
+ const start = sheet.rowCount + 1;
+ for (const line of this.statutory?.partA?.lines ?? []) {
+ // The form's own indentation carries its numbering — (a), then (i) — so a flattened sheet
+ // would lose which line sits under which head.
+ const row = sheet.addRow([
+ `${' '.repeat(line.indent ?? 0)}${line.label}`,
+ ...(line.values ?? [])
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ if (line.heading || line.total) {
+ row.font = { bold: true };
+ }
+ this.formatCimsFigures(row);
+ this.shadeCimsRow(sheet, row, line.heading === true, line.total === true || line.values !== undefined);
+ }
+ boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
+ }
+
+ /**
+ * Part A.II.c — the break-up of "Liabilities in India to Others: c) Others".
+ *
+ * Written with its heading and a nil total: the return does not break that head down, and a sheet
+ * omitted fails the upload where a sheet carrying an honest nil does not.
+ */
+ private addCimsOthersSheet(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Part A.II.c');
+ sheet.columns = [
+ { width: 10 },
+ { width: 50 },
+ ...this.cimsDates.map(() => ({ width: 22 }))
+ ];
+ addBanner(sheet, 'Part A.II.c', this.columnLetter(this.cimsDates.length + 2));
+ sheet.addRow([]);
+
+ const start = sheet.rowCount + 1;
+ const last = this.cimsDates.length + 2;
+ // "As at the close of Business on" heads BOTH dated columns as one caption, as the template has
+ // it — written once and merged across them, not repeated over each.
+ const head = sheet.addRow([
+ 'Sr.No.',
+ 'Particulars',
+ 'As at the close of Business on'
+ ]);
+ head.font = { bold: true };
+ head.alignment = { wrapText: true, horizontal: 'center' };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ if (this.cimsDates.length > 1) {
+ sheet.mergeCells(head.number, 3, head.number, last);
+ }
+ for (let c = 3; c <= last; c++) {
+ head.getCell(c).fill = solidFill(XL.headerGrey);
+ }
+
+ const dates = sheet.addRow([
+ '',
+ '',
+ ...this.cimsDates
+ ]);
+ dates.font = { bold: true };
+ dates.alignment = { horizontal: 'center' };
+ for (let c = 3; c <= last; c++) {
+ dates.getCell(c).fill = solidFill(XL.headerGrey);
+ }
+ // Sr.No. 1, hatched, as the template carries it: the break-up is written in by hand, so the row
+ // has to be there to be written in. An empty sheet with only a Total says the bank reported
+ // nothing; a hatched row says nobody has filled it in yet.
+ const entry = sheet.addRow([
+ 1,
+ '',
+ ...this.cimsDates.map((): null => null)
+ ]);
+ for (let c = 2; c <= last; c++) {
+ entry.getCell(c).fill = hatchFill();
+ }
+
+ const total = sheet.addRow([
+ '',
+ 'Total',
+ ...this.cimsDates.map(() => 0)
+ ]);
+ total.font = { bold: true };
+ total.getCell(1).fill = solidFill(XL.headerGrey);
+ total.getCell(2).alignment = { horizontal: 'center' };
+ total.getCell(2).fill = solidFill(XL.headerGrey);
+ this.formatCimsFigures(total, 3);
+ for (let c = 3; c <= last; c++) {
+ total.getCell(c).fill = solidFill(XL.derivedBlue);
+ }
+ boxAllCells(sheet, start, sheet.rowCount, last);
+ }
+
+ /** Parts B and C on one sheet, as the template has them. */
+ private addCimsPartBandC(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Part B and C');
+ sheet.columns = [
+ { width: 72 },
+ ...this.cimsDates.map(() => ({ width: 22 }))
+ ];
+ addBanner(sheet, 'Part B and C', this.cimsLastColumn());
+ sheet.addRow([]);
+ this.addCimsEndDates(sheet, 'End Date');
+
+ const start = sheet.rowCount + 1;
+ const crr = this.statutory?.crrPercentage;
+ const slr = this.statutory?.slrPercentage;
+
+ this.addCimsSection(sheet, 'Part B: Compliance with section 18 (Applicable to only non-scheduled UCBs)');
+ this.addCimsFigures(
+ sheet,
+ `IX. CRR required to be maintained (prescribed ${this.cimsRate(crr)} of IV b) — ${this.cimsBaseDates()})`,
+ this.statutory?.partB?.required
+ );
+ this.addCimsFigures(sheet, 'X. Cash Reserve actually maintained', this.statutory?.partB?.maintained);
+
+ this.addCimsSection(sheet, 'Part C: Compliance with section 24 (Applicable to only non-scheduled UCBs)');
+ this.addCimsFigures(
+ sheet,
+ `XI. SLR required to be maintained (prescribed ${this.cimsRate(slr)} of IV b) — ${this.cimsBaseDates()})`,
+ this.statutory?.partC?.required
+ );
+ this.addCimsSection(sheet, 'XII. SLR Actually Maintained');
+ // Read off Part C's own lines rather than recomputed here, so this sheet and the screen cannot
+ // come to differ about what made the total up.
+ this.addCimsFigures(
+ sheet,
+ 'a) Cash and Other balances maintained in India (Excess cash reserve maintained but the excess not from DCCBs and StCBs)',
+ this.cimsPartCLine('(a) Cash and other balances')
+ );
+ this.addCimsFigures(sheet, 'b) Gold', this.cimsPartCLine('(b) Gold'));
+ this.addCimsFigures(sheet, 'c) Unencumbered Approved Securities', this.cimsPartCLine('(c) Unencumbered'));
+ this.addCimsFigures(sheet, 'Total of (XII)', this.statutory?.partC?.maintained, true);
+
+ boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
+ }
+
+ /**
+ * Part D — section 24 as scheduled banks file it.
+ *
+ * This is a non-scheduled co-operative bank, which files Parts B and C; Part D is not its return
+ * to make. It is written with the template's rows and nil figures rather than left out, because
+ * what CIMS rejects is a missing sheet, not an empty one.
+ */
+ private addCimsPartD(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Part D');
+ sheet.columns = [
+ { width: 72 },
+ ...this.cimsDates.map(() => ({ width: 22 }))
+ ];
+ addBanner(sheet, 'Part D', this.cimsLastColumn());
+ sheet.addRow([]);
+ this.addCimsEndDates(sheet, 'End Date');
+
+ const start = sheet.rowCount + 1;
+ // Nil throughout, because this bank does not file Part D — but shaded as the template shades
+ // it, so the sheet still reads the way a filer expects: blue where the form would compute a
+ // figure, white where a bank would write one in.
+ const nil = this.cimsDates.map(() => 0);
+ this.addCimsSection(sheet, 'Part D: Compliance with section 24 (Applicable to only scheduled UCBs)');
+ this.addCimsFigures(
+ sheet,
+ 'XIII. SLR required to be maintained (prescribed % of IV as on Last Friday of the Second Preceding fortnight)',
+ nil
+ );
+ this.addCimsSection(sheet, 'XIV. Assets actually maintained');
+ this.addCimsFigures(sheet, 'a) Cash in hand', nil);
+ this.addCimsFigures(
+ sheet,
+ 'b) Balance maintained with RBI in excess of the balance required to be maintained under section 42 of the RBI Act 1934'
+ );
+ this.addCimsFigures(sheet, 'c) Net Balance in Current Accounts (i.e. VIII)', nil);
+ this.addCimsFigures(sheet, 'd) Gold');
+ this.addCimsFigures(sheet, 'e) Unencumbered Approved Securities $$', nil);
+ this.addCimsFigures(sheet, 'f) State Co-op Bank of the State');
+ this.addCimsFigures(sheet, 'g) District Central Co-op Bank');
+ this.addCimsFigures(sheet, 'Total of (XIV) excluding f & g', nil, true);
+ boxAllCells(sheet, start, sheet.rowCount, this.cimsDates.length + 1);
+ }
+
+ /**
+ * The securities valuation sheets — Part C.XII.c and Part D.XIV.e.
+ *
+ * Four valuation columns per period, and the template repeats the whole block for each date. The
+ * rows are the movement of the holding over the fortnight; this return strikes a closing position
+ * and does not carry the movement that produced it, so the opening and the additions are written
+ * nil and the closing carries the figure. A reader then sees the holding that was counted, and
+ * sees that the movement was not reported rather than being told it was zero.
+ */
+ private addCimsSecuritiesSheet(wb: ExcelJS.Workbook, name: string): void {
+ const perDate = 4;
+ const dates = this.cimsDates;
+ const sheet = wb.addWorksheet(name);
+ sheet.columns = [
+ { width: 38 },
+ ...dates.flatMap(() => Array(perDate).fill({ width: 19 }))
+ ];
+ addBanner(sheet, name, this.columnLetter(dates.length * perDate + 1));
+ sheet.addRow([]);
+
+ const dateRow = sheet.addRow([
+ '',
+ ...dates.flatMap((d) => [
+ d,
+ '',
+ '',
+ ''
+ ])
+ ]);
+ dateRow.font = { bold: true };
+ dates.forEach((_, i) => {
+ const from = 2 + i * perDate;
+ sheet.mergeCells(dateRow.number, from, dateRow.number, from + perDate - 1);
+ dateRow.getCell(from).alignment = { horizontal: 'center' };
+ dateRow.getCell(from).fill = solidFill(XL.auditYellow);
+ });
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ '',
+ ...dates.flatMap(() => [
+ 'Face Value',
+ 'Book Value',
+ 'Depreciation',
+ 'Net Value for SLR purpose (2-3)'
+ ])
+ ]);
+ head.font = { bold: true };
+ head.alignment = { wrapText: true, horizontal: 'center' };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ const numbers = sheet.addRow([
+ '',
+ ...dates.flatMap(() => [
+ 1,
+ 2,
+ 3,
+ 4
+ ])
+ ]);
+ numbers.font = { bold: true };
+ numbers.alignment = { horizontal: 'center' };
+
+ // Part C's own securities line, so the valuation sheet totals to the figure the compliance
+ // sheet claimed. Depreciation is nil because the return counts securities at the value the
+ // ledger holds, which is already net of it.
+ const held = this.cimsPartCLine('(c) Unencumbered') ?? dates.map(() => 0);
+ const movement = (label: string, carries: boolean, closing = false) => {
+ const cells: (string | number)[] = [label];
+ for (let i = 0; i < dates.length; i++) {
+ const value = carries ? (held[i] ?? 0) : 0;
+ cells.push(value, value, 0, value);
+ }
+ const row = sheet.addRow(cells);
+ row.getCell(1).fill = solidFill(XL.headerGrey);
+ if (closing) {
+ row.font = { bold: true };
+ }
+ for (let c = 2; c <= dates.length * perDate + 1; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ // The template shades the Net Value column on every row — it is the figure that counts for
+ // SLR — and the whole of a closing balance row, which is the line the return reads off.
+ const isNetValue = (c - 1) % perDate === 0;
+ if (closing || isNetValue) {
+ row.getCell(c).fill = solidFill(XL.derivedBlue);
+ }
+ }
+ };
+
+ this.addCimsSection(sheet, 'Part - I (Government Securities)');
+ movement('Opening Balance', false);
+ movement('Addition during the fortnights', false);
+ movement('Deduction during the fortnights', false);
+ movement('Closing Balance (a)', true, true);
+ this.addCimsSection(sheet, 'Part - II (Other Approved Securities)');
+ movement('Opening Balance', false);
+ movement('Addition during the fortnights', false);
+ movement('Deduction during the fortnights', false);
+ movement('Closing Balance (b)', false, true);
+ movement('Closing Balance (a+b)', true, true);
+ boxAllCells(sheet, start, sheet.rowCount, dates.length * perDate + 1);
+ }
+
+ /**
+ * The daily maintenance sheet the template carries one of per ratio.
+ *
+ * One row a day, under the template's own columns: the base liabilities, what was required, what
+ * was held, and the gap. The gap is split into a Deficit column and a Surplus column rather than
+ * carried as one signed figure, because that is how the template reads it and a filer copying
+ * between the two should not have to re-sign anything.
+ */
+ private addCimsDailySheet(wb: ExcelJS.Workbook, ratio: 'CRR' | 'SLR'): void {
+ const isCrr = ratio === 'CRR';
+ const sheet = wb.addWorksheet(isCrr ? 'Daily CRR maintd (NS)' : 'Daily SLR maintd (S and NS)');
+ sheet.columns = [
+ { width: 9 },
+ { width: 14 },
+ { width: 26 },
+ { width: 21 },
+ { width: 21 },
+ { width: 21 },
+ { width: 21 },
+ { width: 19 },
+ { width: 26 }];
+ addBanner(
+ sheet,
+ isCrr
+ ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]'
+ : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]',
+ 'I'
+ );
+ sheet.addRow([]);
+
+ const start = sheet.rowCount + 1;
+ // The template bands the ratio's name across the four columns it governs, above their own
+ // headings — the first three columns are the day and its base, and belong to neither ratio.
+ const title = isCrr
+ ? 'Cash Reserve Ratio Amount [Applicable to only Non-Scheduled UCBs]'
+ : 'Statutory Liquidity Ratio Amount [Applicable to both Scheduled and Non-Scheduled UCBs]';
+ const band = sheet.addRow([
+ '',
+ '',
+ '',
+ title
+ ]);
+ band.font = { bold: true };
+ band.alignment = { wrapText: true, horizontal: 'center' };
+ sheet.mergeCells(band.number, 4, band.number, 9);
+ for (let c = 1; c <= 9; c++) {
+ band.getCell(c).fill = solidFill(XL.headerGrey);
+ }
+
+ const head = sheet.addRow([
+ 'Sr. No.',
+ 'Date',
+ 'Net Demand and Time Liabilities (As on the base date)',
+ 'Required to be maintained',
+ 'Actually maintained',
+ 'Deficit (Required-Actual)',
+ 'Surplus (Actual-Required)',
+ `${ratio} Interest (Actual Rs.)`,
+ 'Remarks'
+ ]);
+ head.font = { bold: true };
+ head.alignment = { wrapText: true, horizontal: 'center' };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ // The first three columns are one heading spanning both rows, as the template has them.
+ for (const c of [
+ 1,
+ 2,
+ 3
+ ]) {
+ sheet.mergeCells(band.number, c, head.number, c);
+ }
+
+ const days = this.statutory?.daily?.days ?? [];
+ days.forEach((day, i) => {
+ const required = isCrr ? day.crrRequired : day.slrRequired;
+ const held = isCrr ? day.crrMaintained : day.slrMaintained;
+ const gap = isCrr ? day.crrSurplus : day.slrSurplus;
+ const row = sheet.addRow([
+ i + 1,
+ day.date,
+ // Blank, not zero, where an older snapshot never carried the base: a nil in this column
+ // would read as a day with no liabilities, which is a different claim from not knowing.
+ day.baseLiabilities ?? '',
+ required,
+ held,
+ gap < 0 ? -gap : 0,
+ gap > 0 ? gap : 0,
+ '',
+ // The template gives NDTL one column, and it is the base's — so the day's own liabilities
+ // go in Remarks rather than in a column the template does not have. A reader can still see
+ // which days the bank's position moved without the sheet departing from the filed shape.
+ this.cimsDailyRemark(day, gap)
+ ]);
+ for (let c = 3; c <= 8; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ // A day short is the thing this sheet exists to show, and in a column of near-identical
+ // figures it is exactly what the eye goes past.
+ if (gap < 0) {
+ row.font = { color: { argb: 'FFC5221F' } };
+ }
+ });
+
+ boxAllCells(sheet, start, sheet.rowCount, 9);
+ }
+
+ /**
+ * What a daily row's Remarks column says: the base the day was charged on, the day's own
+ * liabilities, and whether it fell short.
+ */
+ private cimsDailyRemark(day: Form1DailyRow, gap: number): string {
+ const parts = [`Charged on ${day.baseDate}`];
+ if (day.ownLiabilities !== undefined) {
+ parts.push(`NDTL on the day ${day.ownLiabilities.toFixed(2)}`);
+ }
+ if (gap < 0) {
+ parts.push('SHORT');
+ }
+ return parts.join('; ');
+ }
+
+ private addCimsSignatory(wb: ExcelJS.Workbook): void {
+ const sheet = wb.addWorksheet('Signatory Information');
+ sheet.columns = [
+ { width: 26 },
+ { width: 36 },
+ { width: 36 }];
+ addBanner(sheet, 'Signatory Information', 'C');
+ sheet.addRow([]);
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ '',
+ 'Authorised Reporting Official',
+ 'Countersigned By'
+ ]);
+ head.font = { bold: true };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ // Left to be signed, and hatched the way the template hatches it so the blank reads as "fill
+ // this in" rather than as a field the return had nothing to say about. Who signs a filed return,
+ // and on what date, is settled when it is filed — a name pre-filled here would put a signature
+ // on a form nobody had agreed to sign.
+ for (const field of [
+ 'Name',
+ 'Designation',
+ 'e-mail ID',
+ 'Tel. No. (O)',
+ 'Tel. No. (R)',
+ 'Place',
+ 'Date'
+ ]) {
+ const row = sheet.addRow([
+ field,
+ '',
+ ''
+ ]);
+ row.getCell(1).font = { bold: true };
+ row.getCell(1).fill = solidFill(XL.keyCell);
+ row.getCell(2).fill = hatchFill();
+ row.getCell(3).fill = hatchFill();
+ }
+ boxAllCells(sheet, start, sheet.rowCount, 3);
+ }
+
+ /**
+ * The header every CIMS data sheet opens with: the dates its columns report.
+ *
+ * Yellow, as the template has it — the one thing on the sheet a filer sets, so it is the one
+ * thing not shaded like the figures below it.
+ */
+ private addCimsEndDates(sheet: ExcelJS.Worksheet, label: string): void {
+ const row = sheet.addRow([
+ label,
+ ...this.cimsDates
+ ]);
+ row.font = { bold: true };
+ row.getCell(1).alignment = { horizontal: 'right' };
+ for (let c = 2; c <= this.cimsDates.length + 1; c++) {
+ row.getCell(c).alignment = { horizontal: 'center' };
+ row.getCell(c).fill = solidFill(XL.auditYellow);
+ }
+ }
+
+ /**
+ * The template's shading, which is what makes one of its sheets readable at a glance.
+ *
+ * Three states, and the colour is the only thing that tells them apart: every label cell is grey,
+ * a figure the form computes is blue, and a figure a bank writes in by hand is left white. On a
+ * sheet where most of the numbers are 0.00 that is the whole of the information — blue says "this
+ * is nil because the form worked it out", white says "nobody has filled this in". An all-white
+ * sheet makes those two look identical.
+ */
+ private shadeCimsRow(sheet: ExcelJS.Worksheet, row: ExcelJS.Row, heading: boolean, computed: boolean): void {
+ row.getCell(1).fill = solidFill(XL.headerGrey);
+ const last = this.cimsDates.length + 1;
+ for (let c = 2; c <= last; c++) {
+ if (heading) {
+ // A heading has no figures of its own; the template greys the whole band.
+ row.getCell(c).fill = solidFill(XL.headerGrey);
+ } else if (computed) {
+ row.getCell(c).fill = solidFill(XL.derivedBlue);
+ }
+ }
+ }
+
+ private addCimsSection(sheet: ExcelJS.Worksheet, label: string): void {
+ const row = sheet.addRow([label]);
+ row.font = { bold: true };
+ row.getCell(1).alignment = { wrapText: true };
+ row.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+ }
+
+ /**
+ * One figure line across the dated columns.
+ *
+ * A line the return computes is written and shaded blue. A line it does not carry is left white
+ * and empty rather than filled with zeros: on Part D, which this bank does not file, a column of
+ * 0.00 claims the bank worked the figure out and found nothing, when in truth it never reported
+ * that part at all.
+ */
+ private addCimsFigures(sheet: ExcelJS.Worksheet, label: string, values?: number[], bold = false): void {
+ const row = sheet.addRow([
+ label,
+ ...(values ?? this.cimsDates.map((): null => null))
+ ]);
+ row.getCell(1).alignment = { wrapText: true };
+ if (bold) {
+ row.font = { bold: true };
+ }
+ this.formatCimsFigures(row);
+ this.shadeCimsRow(sheet, row, false, values !== undefined);
+ }
+
+ private formatCimsFigures(row: ExcelJS.Row, from = 2): void {
+ for (let c = from; c < from + this.cimsDates.length; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ }
+
+ /**
+ * The rate a requirement was struck at, as it is named on the form.
+ *
+ * "rate not set" rather than a blank or a zero: a requirement computed at no rate is not a
+ * requirement of nothing, and a statutory line that reads "0% of IV b)" states a rate the bank
+ * was never given.
+ */
+ private cimsRate(rate: number | null | undefined): string {
+ return rate === null || rate === undefined ? 'rate not set' : `${rate}%`;
+ }
+
+ /**
+ * The base dates the two columns are charged on, for the wording of the requirement lines.
+ *
+ * The template says "Last Friday of the Second Preceding fortnight". This bank's base rule is the
+ * previous month's matching fortnight close, so the dates themselves are printed instead — a
+ * filer can check them, where a Friday that was never used cannot be checked at all.
+ */
+ private cimsBaseDates(): string {
+ const bases = this.statutory?.baseColumns ?? [];
+ return bases.length ? bases.join(' / ') : 'base date not stated';
+ }
+
+ /**
+ * Whether the bank's own liabilities moved on this day from the day before.
+ *
+ * The daily table is thirty rows that mostly repeat, and the days worth looking at are the ones
+ * where something changed. Marked rather than left to be spotted: a reader scanning a column of
+ * near-identical figures will not find the row where the fourth digit differs.
+ *
+ * The first day has nothing before it in the table, so it is never marked — it is a starting
+ * position, not a movement.
+ */
+ dailyOwnMoved(day: Form1DailyRow, index: number): boolean {
+ if (index === 0 || day.ownLiabilities === undefined) {
+ return false;
+ }
+ const previous = this.statutory?.daily?.days?.[index - 1]?.ownLiabilities;
+ return previous !== undefined && previous !== day.ownLiabilities;
+ }
+
+ /** A part's line, found by the opening of its label, as its values across the dated columns. */
+ private cimsPartCLine(startsWith: string): number[] | undefined {
+ return this.statutory?.partC?.lines?.find((line) => (line.label ?? '').startsWith(startsWith))?.values;
+ }
+
+ private cimsLastColumn(): string {
+ return this.columnLetter(this.cimsDates.length + 1);
+ }
+
+ /** Spreadsheet column letter for a 1-based index — A, B, … Z, AA. */
+ private columnLetter(index: number): string {
+ let n = index;
+ let letters = '';
+ while (n > 0) {
+ const remainder = (n - 1) % 26;
+ letters = String.fromCharCode(65 + remainder) + letters;
+ n = Math.floor((n - 1) / 26);
+ }
+ return letters;
+ }
+
+ /**
+ * Form 1 saves the statutory form, because that is the only Form 1 this screen shows.
+ *
+ * Saved under its own form code rather than FORM_1: a saved return is rendered from its stored
+ * snapshot, and a statutory statement filed under the working report's code would be opened by
+ * the reader built for the other shape.
+ */
+
+ /**
+ * The day-by-day position, as its own sheet in the workbook.
+ *
+ * Written only where the return carries it, so a saved snapshot taken before this existed still
+ * exports rather than failing on a sheet it has no rows for.
+ */
+ private addDailySheet(wb: ExcelJS.Workbook): void {
+ const daily = this.statutory?.daily;
+ if (!daily?.days?.length) {
+ return;
+ }
+ const sheet = wb.addWorksheet('Day by day');
+ sheet.columns = [
+ { width: 14 },
+ { width: 18 },
+ { width: 14 },
+ { width: 18 },
+ { width: 18 },
+ { width: 18 },
+ { width: 20 },
+ { width: 18 },
+ { width: 18 },
+ { width: 20 }];
+ addBanner(sheet, `Day by day — ${daily.from} to ${daily.to}`, 'J');
+
+ // A day short is a breach. It goes above the table, where it is read before the figures rather
+ // than hunted for among thirty rows.
+ if (daily.crrShortDays || daily.slrShortDays) {
+ const warn = sheet.addRow([
+ `CRR short on ${daily.crrShortDays} of ${daily.dayCount} days; SLR short on ${daily.slrShortDays}.`
+ ]);
+ warn.font = { bold: true, color: { argb: 'FFC5221F' } };
+ sheet.addRow([]);
+ }
+
+ const start = sheet.rowCount + 1;
+ const head = sheet.addRow([
+ 'Date',
+ 'NDTL on the day',
+ 'Charged on',
+ 'Base NDTL',
+ 'CRR required',
+ 'CRR maintained',
+ 'CRR surplus / (short)',
+ 'SLR required',
+ 'SLR maintained',
+ 'SLR surplus / (short)'
+ ]);
+ head.font = { bold: true };
+ head.alignment = { wrapText: true, horizontal: 'center' };
+ head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
+
+ for (const day of daily.days) {
+ const row = sheet.addRow([
+ day.date,
+ day.ownLiabilities ?? '',
+ day.baseDate,
+ day.baseLiabilities ?? '',
+ day.crrRequired,
+ day.crrMaintained,
+ day.crrSurplus,
+ day.slrRequired,
+ day.slrMaintained,
+ day.slrSurplus
+ ]);
+ // Column 3 is a date, so the figures run 2, then 4 onward.
+ for (const c of [
+ 2,
+ 4,
+ 5,
+ 6,
+ 7,
+ 8,
+ 9,
+ 10
+ ]) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ // A shortfall is marked on the row, not only by its sign: a minus in a column of figures is
+ // the easiest thing on a printed sheet to read past.
+ if (day.crrSurplus < 0 || day.slrSurplus < 0) {
+ row.font = { color: { argb: 'FFC5221F' } };
+ }
+ }
+
+ sheet.addRow([]);
+ // Under the Maintained columns the averages belong to — column 6 for CRR, column 9 for SLR.
+ const avg = sheet.addRow([
+ 'Average held',
+ '',
+ '',
+ '',
+ '',
+ daily.crrAverage,
+ '',
+ '',
+ daily.slrAverage,
+ ''
+ ]);
+ avg.font = { bold: true };
+ avg.getCell(6).numFmt = '#,##0.00';
+ avg.getCell(9).numFmt = '#,##0.00';
+ boxAllCells(sheet, start, sheet.rowCount, 10);
+ }
+
+ /** One of a part's computed rows, written only where the part carries it. */
+ private addStatutoryTotals(sheet: ExcelJS.Worksheet, label: string, values?: number[]): void {
+ if (!values?.length) {
+ return;
+ }
+ const row = sheet.addRow([
+ label,
+ ...values
+ ]);
+ row.font = { bold: true };
+ // The values themselves say how many columns there are, so this row formats what it wrote.
+ const lastColumn = values.length + 1;
+ for (let c = 2; c <= lastColumn; c++) {
+ row.getCell(c).numFmt = '#,##0.00';
+ row.getCell(c).alignment = { horizontal: 'right' };
+ }
+ }
+}
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.component.html b/src/app/rbi-circulars/reserve-return/reserve-return.component.html
new file mode 100644
index 000000000..ddf57dc2c
--- /dev/null
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html
@@ -0,0 +1,1116 @@
+
+
+
+ Choose the date the return is made up to. The base is read at an earlier date than the balances maintained, and
+ the return states both on its face once it runs.
+
+
+
+
+ @if (hasBaseDate) {
+
+ Base date (optional)
+
+
+ }
+
+ Reporting date
+
+
+ Generate report
+
+
+ @if (chosenFortnight) {
+
+ Fortnight: {{ chosenFortnight }}
+ @if (hasBaseDate && !baseDate) {
+ · base will be derived from the 45-day lag
+ }
+
+ }
+
+
+
+ Jump to:
+ 15th
+ Month end
+ @if (hasBaseDate && baseDate) {
+ Clear base date
+ }
+
{{ data.rateType }} required ({{ data.ratePercentage }}% of NDTL)
+
+
{{ amount(data.requiredAmount) }}
+
+
+
+
{{ data.rateType }} maintained
+
+
{{ amount(data.maintainedAmount) }}
+
+
+
+
{{ isShortfall ? 'Shortfall' : 'Surplus' }}
+
+
{{ amount(shortfallAmount) }}
+
+
+ }
+
+
+
+
+
+ @if (data.maintenance; as m) {
+
+
+ Daily maintenance — {{ m.periodLabel || 'this month' }}
+ @if (!m.fortnightComplete) {
+ In progress — {{ m.dayCount }} days so far
+ }
+
+
+ Every day of the month, against the requirement in force. CRR is reckoned over the period rather than on one
+ day: the full requirement must be held on average, and at least
+ {{ m.dailyMinimumPercent }}% of it on every single day.
+
+ Made up to {{ statutorySecondColumn }}. The base dates above are the matching close one month earlier — change either one if the bank files on
+ a different base.
+
+ } @else {
+
Enter both fortnight closing dates — the base dates fill in from them.
+ }
+
+
+
+ Fill:
+ This month's 15th & month end
+ @if (statutoryFirstColumn || statutorySecondColumn) {
+ Clear
+ }
+
+ } @else {
+
+
+
+ Reporting date
+
+
+
+ Generate
+
+
+ @if (statutoryDate) {
+
+ The return reports the month ending on or before {{ statutoryDate }}, and charges the ratio on the matching close one month earlier.
+
+ }
+ }
+
+ @if (statutoryError) {
+
{{ statutoryError }}
+ }
+
+ } @else if (statutoryLoading) {
+
Computing from the ledger…
+ } @else if (statutoryError) {
+
{{ statutoryError }}
+ } @else if (statutory) {
+
+ Made up to {{ statutory.reportingDate }}
+ Change date
+
+
+
+ Print
+
+
+
+ {{ saving ? 'Saving…' : 'Save' }}
+
+
+
+ {{ generating ? 'Generating…' : 'Export' }}
+
+
+
+
+
+
+
{{ statutory.formName }}
+
{{ statutory.statutoryReference }}
+
+
+ @if (statutory.unmetRequirements.length) {
+
+ Check before filing.
+
+ @for (reason of statutory.unmetRequirements; track reason) {
+
{{ reason }}
+ }
+
+
+ }
+
+
+
+
+
Particulars
+
+ @for (col of statutory.columns; track col; let i = $index) {
+
+ {{ col }}
+ fortnight close
+ @if (statutory.baseColumns?.[i]) {
+ charged on {{ statutory.baseColumns[i] }}
+ }
+
+ Returns kept for the record. Each opens exactly as it was saved — the stored statement, not a re-run of today's
+ ledger, so a figure already reported cannot change underneath it.
+
+ Saved {{ viewingSaved.createdOn }} by {{ viewingSaved.createdBy || 'unknown' }}. These are the figures as
+ they stood when it was saved — not today's ledger, which may have moved since.
+ @if (viewingSaved.remarks) {
+ “{{ viewingSaved.remarks }}”
+ }
+
+ {{ deletingEntity ? 'Record To Be Deleted' : ('labels.heading.Command' | translate) }}
+
@for (field of commandFields; track $index) {
diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss
index e1d13e084..9bb961c1c 100644
--- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss
+++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.scss
@@ -304,6 +304,97 @@ $bg-page: #f8fafc;
color: $text-soft;
}
+/* ── UPDATE review: one row per field that actually moved ──────────────────── */
+.vci-changes {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ background: $border;
+}
+
+.vci-change {
+ background: #fff;
+ padding: 11px 16px;
+
+ &__head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ margin-bottom: 5px;
+ }
+
+ &__label {
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: $text-soft;
+ }
+
+ &__kind {
+ font-size: 9.5px;
+ font-weight: 700;
+ letter-spacing: 0.07em;
+ padding: 1px 7px;
+ border-radius: 2px;
+
+ &--modified {
+ background: #e0f2fe;
+ color: #0369a1;
+ }
+
+ &--added {
+ background: #dcfce7;
+ color: #15803d;
+ }
+
+ &--removed {
+ background: #fee2e2;
+ color: #991b1b;
+ }
+
+ /* Not a change: the entity has no such field, so there was no before-value to compare. */
+ &--submitted {
+ background: #eef1f4;
+ color: #5b6673;
+ }
+ }
+
+ &__values {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ flex-wrap: wrap;
+ font-size: 14px;
+ min-width: 0;
+ }
+
+ &__old {
+ color: $text-soft;
+ text-decoration: line-through;
+ overflow-wrap: anywhere;
+ }
+
+ &__arrow {
+ color: $text-soft;
+ font-size: 11px;
+ flex: none;
+ }
+
+ &__new {
+ color: $text-dark;
+ font-weight: 600;
+ overflow-wrap: anywhere;
+ }
+}
+
+.vci-nochange {
+ margin: 0;
+ padding: 16px;
+ font-size: 13.5px;
+ color: $text-soft;
+}
+
.vci-tag {
display: inline-flex;
align-items: center;
diff --git a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts
index cfc3cb336..ade448188 100644
--- a/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts
+++ b/src/app/tasks/view-checker-inbox/view-checker-inbox.component.ts
@@ -14,6 +14,7 @@ import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dial
/** Custom Services */
import { TasksService } from '../tasks.service';
+import { SystemService } from 'app/system/system.service';
/** Dialog Components */
import { ConfirmationDialogComponent } from 'app/shared/confirmation-dialog/confirmation-dialog.component';
@@ -23,6 +24,24 @@ import { NgTemplateOutlet } from '@angular/common';
import { DateFormatPipe } from '../../pipes/date-format.pipe';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { isTempTableProductEdit } from '../product-entities';
+import {
+ CommandChange,
+ diffCommandAgainstEntity,
+ displayScalar,
+ groupCreateFields,
+ humanizeKey
+} from '../command-diff.util';
+import { deleteSummaryKeys, EntityFetchServices, fetchCurrentEntity } from '../maker-checker-entity-fetch';
+
+/** Services the entity lookup table needs; assembled once and handed to the shared fetcher. */
+import { ProductsService } from 'app/products/products.service';
+import { ClientsService } from 'app/clients/clients.service';
+import { SavingsService } from 'app/savings/savings.service';
+import { AccountingService } from 'app/accounting/accounting.service';
+import { UsersService } from 'app/users/users.service';
+import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
+import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
+import { OrganizationService } from 'app/organization/organization.service';
/**
* One row of the rendered Command payload. The dialog turns the parsed command JSON into a tree of
@@ -35,6 +54,17 @@ export type CommandField =
| { label: string; kind: 'object'; fields: CommandField[] }
| { label: string; kind: 'list'; items: CommandField[] };
+/**
+ * Transport-level fields every Fineract command carries. They describe how the request was encoded,
+ * not what the maker actually changed, so they are dropped before the payload is shown - present in
+ * roughly three quarters of all stored commands, and pure noise to a checker.
+ */
+const NOISE_KEYS = new Set([
+ 'locale',
+ 'dateFormat',
+ 'monthDayFormat'
+]);
+
/**
* Segments that read as initialisms in this domain, so `upiFacility` becomes "UPI Facility" rather
* than "Upi Facility". Anything not listed just gets title-cased, so an unknown key still renders
@@ -76,6 +106,24 @@ const LABEL_ACRONYMS = new Set([
'nef'
]);
+/**
+ * Fields that identify one element of an array, tried in order. Keeping this as a list of names rather
+ * than per-entity rules means an unfamiliar collection still gets a readable heading if it happens to
+ * carry any of them.
+ */
+const ENTRY_LABEL_KEYS = [
+ 'limitType',
+ 'name',
+ 'displayName',
+ 'chargeName',
+ 'title',
+ 'label',
+ 'type',
+ 'description',
+ 'code',
+ 'value'
+];
+
@Component({
selector: 'mifosx-view-checker-inbox',
templateUrl: './view-checker-inbox.component.html',
@@ -93,6 +141,30 @@ export class ViewCheckerInboxComponent {
private router = inject(Router);
private translateService = inject(TranslateService);
private tasksService = inject(TasksService);
+ private systemService = inject(SystemService);
+ private productsService = inject(ProductsService);
+ private clientsService = inject(ClientsService);
+ private savingsService = inject(SavingsService);
+ private accountingService = inject(AccountingService);
+ private usersService = inject(UsersService);
+ private fixedDepositsService = inject(FixedDepositsService);
+ private recurringDepositsService = inject(RecurringDepositsService);
+ private organizationService = inject(OrganizationService);
+
+ /** Bundle handed to the shared entity lookup, so that table stays free of Angular injection. */
+ private get entityFetchServices(): EntityFetchServices {
+ return {
+ products: this.productsService,
+ clients: this.clientsService,
+ savings: this.savingsService,
+ system: this.systemService,
+ accounting: this.accountingService,
+ users: this.usersService,
+ fixedDeposits: this.fixedDepositsService,
+ recurringDeposits: this.recurringDepositsService,
+ organization: this.organizationService
+ };
+ }
/** Present only when opened as a popup from the Checker Inbox list; absent when reached by direct
* route (a bookmarked or shared link). Both entry points stay supported — this only changes where
* the detail comes from and how "done" is signalled: closing the dialog vs. navigating back. */
@@ -122,6 +194,17 @@ export class ViewCheckerInboxComponent {
*/
commandFields: CommandField[] = [];
+ /** UPDATE review: the fields this command actually moves, as OLD -> NEW. */
+ changes: CommandChange[] = [];
+ /** True once the current entity was read and a real comparison is on screen. */
+ showChanges = false;
+ /** True while the current entity is being fetched for comparison. */
+ comparing = false;
+ /** True when the screen is describing a record that is about to be deleted. */
+ deletingEntity = false;
+ /** CREATE review: submitted data bucketed into readable sections. */
+ createSections: { title: string; fields: CommandField[] }[] = [];
+
/** camelCase / snake_case key -> readable label, e.g. `upiFacility` -> "UPI Facility". */
private labelFor(key: string): string {
const spaced = String(key ?? '')
@@ -156,6 +239,27 @@ export class ViewCheckerInboxComponent {
* Recursively turns one key/value pair into a render node. Nothing is dropped or summarised - every
* key in the payload produces a node, so the checker still sees the complete request.
*/
+ /**
+ * Names one element of an array of objects.
+ *
+ * Numbering them "Account Facility Limits 1 ... 10" tells a checker nothing - the position in a JSON
+ * array is not a fact about the record. Most entries carry a field that identifies them (`limitType`,
+ * a charge's `name`, a chart's `description`), so that value is used as the heading and the ordinal
+ * is only a fallback for entries that have nothing identifying at all.
+ */
+ private entryLabel(item: Record, listLabel: string, index: number): string {
+ for (const key of ENTRY_LABEL_KEYS) {
+ const candidate = item?.[key];
+ if (typeof candidate === 'string' && candidate.trim() !== '') {
+ return candidate.trim();
+ }
+ if (typeof candidate === 'number') {
+ return String(candidate);
+ }
+ }
+ return `${listLabel} ${index + 1}`;
+ }
+
private toField(label: string, value: any): CommandField {
if (Array.isArray(value)) {
return {
@@ -163,7 +267,7 @@ export class ViewCheckerInboxComponent {
kind: 'list',
items: value.map((item, index) =>
item !== null && typeof item === 'object'
- ? this.toField(`${label} ${index + 1}`, item)
+ ? this.toField(this.entryLabel(item, label, index), item)
: { label: '', kind: 'scalar' as const, text: this.scalarText(item) }
)
};
@@ -172,7 +276,9 @@ export class ViewCheckerInboxComponent {
return {
label,
kind: 'object',
- fields: Object.keys(value).map((key) => this.toField(this.labelFor(key), value[key]))
+ fields: Object.keys(value)
+ .filter((key) => !NOISE_KEYS.has(key))
+ .map((key) => this.toField(this.labelFor(key), value[key]))
};
}
return { label, kind: 'scalar', text: this.scalarText(value) };
@@ -182,7 +288,9 @@ export class ViewCheckerInboxComponent {
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
return [];
}
- return Object.keys(payload).map((key) => this.toField(this.labelFor(key), payload[key]));
+ return Object.keys(payload)
+ .filter((key) => !NOISE_KEYS.has(key))
+ .map((key) => this.toField(this.labelFor(key), payload[key]));
}
/** Checker Inbox Details Data */
@@ -238,6 +346,14 @@ export class ViewCheckerInboxComponent {
private applyDetail(detail: any): void {
this.checkerInboxDetail = detail;
+ // Angular reuses this component when only the route's id changes, so the previous request's review
+ // state has to be cleared explicitly — otherwise a DELETE opened after an UPDATE keeps showing the
+ // UPDATE's changes.
+ this.changes = [];
+ this.showChanges = false;
+ this.comparing = false;
+ this.deletingEntity = false;
+ this.createSections = [];
// commandAsJson is absent on some entries (a DELETE carries no payload), and JSON.parse(undefined)
// throws — which as a route only broke the page, but in a dialog would leave an empty shell open.
try {
@@ -247,6 +363,274 @@ export class ViewCheckerInboxComponent {
}
this.displayJSONData = !_.isEmpty(this.jsondata);
this.commandFields = this.buildCommandFields(this.jsondata);
+
+ // Role permissions get their own treatment: the payload is the role's whole permission map, so a
+ // field-by-field diff would be 1,100 rows rather than the handful that moved.
+ if (this.isRolePermissionsCommand(detail)) {
+ this.loadRolePermissionChanges(detail.resourceId, this.jsondata.permissions);
+ return;
+ }
+
+ const action = (detail?.actionName ?? '').toUpperCase();
+ if (action === 'UPDATE') {
+ this.loadUpdateChanges(detail);
+ } else if (action === 'DELETE') {
+ this.loadDeleteSummary(detail);
+ } else if (action === 'CREATE') {
+ this.createSections = this.buildCreateSections(this.jsondata);
+ if ((detail?.entityName ?? '').toUpperCase() === 'GLACCOUNT') {
+ this.resolveGlAccountCreateLabels(detail, this.jsondata);
+ }
+ }
+ }
+
+ /**
+ * UPDATE: read the entity as it stands today and show only what the command actually moves.
+ *
+ * Several screens submit their entire form regardless of what the maker touched - a fixed deposit
+ * account update posts 28 fields every time - so without this the checker cannot tell which one
+ * changed. If the entity cannot be read the submitted payload stays on screen, which is what every
+ * entity showed before.
+ */
+ private loadUpdateChanges(detail: any): void {
+ const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
+ if (!request$) {
+ return;
+ }
+ this.comparing = true;
+ request$.subscribe({
+ next: (current: any) => {
+ // A slow response for a record the user has already navigated away from must not overwrite the
+ // record now on screen.
+ if (!this.isCurrent(detail)) {
+ return;
+ }
+ this.comparing = false;
+ this.changes = diffCommandAgainstEntity(this.jsondata ?? {}, current ?? {});
+ this.showChanges = true;
+ },
+ error: () => {
+ this.comparing = false;
+ }
+ });
+ }
+
+ /**
+ * DELETE: the payload is empty for every entity, so the command alone tells the checker nothing.
+ * Reads the record instead and shows the few fields that identify it.
+ */
+ private loadDeleteSummary(detail: any): void {
+ const request$ = fetchCurrentEntity(detail?.entityName, detail?.resourceId, this.entityFetchServices);
+ if (!request$) {
+ return;
+ }
+ this.comparing = true;
+ request$.subscribe({
+ next: (current: any) => {
+ if (!this.isCurrent(detail)) {
+ return;
+ }
+ this.comparing = false;
+ const keys = deleteSummaryKeys(detail?.entityName);
+ const fields = keys
+ .filter((key) => current?.[key] !== undefined && current?.[key] !== null && current?.[key] !== '')
+ .map((key) => ({
+ label: humanizeKey(key),
+ kind: 'scalar' as const,
+ text: displayScalar(current[key])
+ }));
+ if (fields.length > 0) {
+ this.commandFields = fields;
+ this.displayJSONData = true;
+ this.deletingEntity = true;
+ }
+ },
+ error: () => {
+ this.comparing = false;
+ }
+ });
+ }
+
+ /** Whether `detail` is still the request on screen, i.e. an in-flight lookup is not stale. */
+ private isCurrent(detail: any): boolean {
+ return this.checkerInboxDetail?.id === detail?.id;
+ }
+
+ /** CREATE: nothing to compare against, so the submitted data is grouped rather than diffed. */
+ private buildCreateSections(payload: any): { title: string; fields: CommandField[] }[] {
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
+ return [];
+ }
+ return groupCreateFields(payload).map((section) => ({
+ title: section.title,
+ fields: section.keys.map((key) => this.toField(this.labelFor(key), payload[key]))
+ }));
+ }
+
+ /**
+ * GL Account CREATE: `type`, `usage`, `parentId` and `tagId` arrive as raw numeric ids - meaningless
+ * to a checker without the chart-of-accounts template, which is the same lookup data the create-GL
+ * form itself already uses (`create-gl-account.component.ts`). Fetched once, purely for display: the
+ * stored command payload (`jsondata`) and the already-rendered raw sections are left as they are
+ * unless the lookup succeeds, so a slow or failed template call never blocks or breaks this screen.
+ */
+ private resolveGlAccountCreateLabels(detail: any, payload: Record): void {
+ this.accountingService.getChartOfAccountsTemplate().subscribe({
+ next: (template: any) => {
+ // A slow response for a request the checker has already navigated away from must not overwrite
+ // whatever is now on screen.
+ if (!this.isCurrent(detail)) {
+ return;
+ }
+ const resolved = { ...payload };
+ const typeId = payload.type;
+
+ const typeOption = (template?.accountTypeOptions ?? []).find((option: any) => option.id === typeId);
+ if (typeOption) {
+ resolved.type = this.translateService.instant(`labels.inputs.accounting.${typeOption.value}`);
+ }
+
+ const usageOption = (template?.usageOptions ?? []).find((option: any) => option.id === payload.usage);
+ if (usageOption) {
+ resolved.usage = usageOption.value;
+ }
+
+ const parentAccount = this.glHeaderAccountOptions(template, typeId).find(
+ (account: any) => account.id === payload.parentId
+ );
+ if (parentAccount) {
+ resolved.parentId = parentAccount.glCode
+ ? `${parentAccount.name} (${parentAccount.glCode})`
+ : parentAccount.name;
+ }
+
+ const tagOption = this.glTagOptions(template, typeId).find((tag: any) => tag.id === payload.tagId);
+ if (tagOption) {
+ resolved.tagId = tagOption.name;
+ }
+
+ this.createSections = this.buildCreateSections(resolved);
+ },
+ // Lookup failed - the raw ids already on screen from buildCreateSections() above stay as they are.
+ error: () => undefined
+ });
+ }
+
+ /** Header/parent account options are partitioned by GL account type, same as the create-GL form. */
+ private glHeaderAccountOptions(template: any, typeId: number): any[] {
+ switch (typeId) {
+ case 1:
+ return template?.assetHeaderAccountOptions ?? [];
+ case 2:
+ return template?.liabilityHeaderAccountOptions ?? [];
+ case 3:
+ return template?.equityHeaderAccountOptions ?? [];
+ case 4:
+ return template?.incomeHeaderAccountOptions ?? [];
+ case 5:
+ return template?.expenseHeaderAccountOptions ?? [];
+ default:
+ return [];
+ }
+ }
+
+ /** Allowed tag options are partitioned by GL account type, same as the create-GL form. */
+ private glTagOptions(template: any, typeId: number): any[] {
+ switch (typeId) {
+ case 1:
+ return template?.allowedAssetsTagOptions ?? [];
+ case 2:
+ return template?.allowedLiabilitiesTagOptions ?? [];
+ case 3:
+ return template?.allowedEquityTagOptions ?? [];
+ case 4:
+ return template?.allowedIncomeTagOptions ?? [];
+ case 5:
+ return template?.allowedExpensesTagOptions ?? [];
+ default:
+ return [];
+ }
+ }
+
+ /**
+ * A role permission update posts the role's *entire* permission map - over 1,100 entries, of which
+ * a handful are actually being changed. Rendering that as-is buries the change completely, so this
+ * screen fetches the role's current permissions and shows only the difference.
+ */
+ private isRolePermissionsCommand(detail: any): boolean {
+ return (
+ (detail?.entityName ?? '').toUpperCase() === 'ROLE' &&
+ (detail?.actionName ?? '').toUpperCase() === 'PERMISSIONS' &&
+ detail?.resourceId != null &&
+ !!this.jsondata?.permissions
+ );
+ }
+
+ /**
+ * Replaces the flat permission dump with Granted / Revoked lists, by comparing the submitted map
+ * against what the role holds today. Anything the maker left untouched is summarised as a count
+ * rather than listed.
+ *
+ * If the role lookup fails the original payload stays on screen - a degraded but complete view is
+ * better than showing the checker nothing.
+ */
+ private loadRolePermissionChanges(roleId: string | number, submitted: Record): void {
+ this.systemService.getRole(roleId).subscribe({
+ next: (role: any) => {
+ const current = new Map(
+ (role?.permissionUsageData ?? []).map((p: any): [string, boolean] => [
+ p.code,
+ !!p.selected
+ ])
+ );
+
+ const granted: string[] = [];
+ const revoked: string[] = [];
+ let unchanged = 0;
+
+ Object.keys(submitted).forEach((code) => {
+ const now = !!submitted[code];
+ const before = current.get(code);
+ // A code missing from the role lookup has no "before" to compare against, so treat it as
+ // changed only when it is being switched on.
+ if (before === undefined) {
+ if (now) {
+ granted.push(code);
+ }
+ return;
+ }
+ if (now === before) {
+ unchanged++;
+ } else if (now) {
+ granted.push(code);
+ } else {
+ revoked.push(code);
+ }
+ });
+
+ this.commandFields = this.buildPermissionChangeFields(granted, revoked, unchanged);
+ },
+ error: () => undefined
+ });
+ }
+
+ private buildPermissionChangeFields(granted: string[], revoked: string[], unchanged: number): CommandField[] {
+ const fields: CommandField[] = [];
+ const asList = (label: string, codes: string[]): CommandField => ({
+ label,
+ kind: 'list',
+ items: codes.sort().map((code) => ({ label: '', kind: 'scalar' as const, text: this.labelFor(code) }))
+ });
+
+ fields.push(asList(`Permissions Granted (${granted.length})`, granted));
+ fields.push(asList(`Permissions Revoked (${revoked.length})`, revoked));
+
+ if (granted.length === 0 && revoked.length === 0) {
+ fields.push({ label: 'Result', kind: 'scalar', text: 'No permission changes in this request.' });
+ }
+ fields.push({ label: 'Unchanged Permissions', kind: 'scalar', text: `${unchanged} left as they were` });
+
+ return fields;
}
/** "Done" for this screen — closes the popup (telling the list to refresh) when opened as a
@@ -304,23 +688,4 @@ export class ViewCheckerInboxComponent {
}
});
}
-
- /**
- * Delete Checker
- */
- deleteChecker() {
- const deleteCheckerDialogRef = this.dialog.open(ConfirmationDialogComponent, {
- data: {
- heading: this.translateService.instant('labels.heading.Delete Checker'),
- dialogContext: this.translateService.instant('labels.dialogContext.Are you sure you want to delete checker')
- }
- });
- deleteCheckerDialogRef.afterClosed().subscribe((response: { confirm: any }) => {
- if (response.confirm) {
- this.tasksService.deleteMakerChecker(this.checkerInboxDetail.id).subscribe((res: any) => {
- this.finish();
- });
- }
- });
- }
}
diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts
new file mode 100644
index 000000000..dbb9cbc9b
--- /dev/null
+++ b/src/app/tds-reports/tds-interest-export.spec.ts
@@ -0,0 +1,164 @@
+/**
+ * @jest-environment-options {"customExportConditions": ["node", "node-addons"]}
+ */
+/**
+ * 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 { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
+import { buildTdsInterestWorkbook } from './tds-interest-export';
+import { TdsDeductee, setTdsDeductees } from './tds-reports-data';
+import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute';
+
+installTdsConfigFixture();
+
+const FY = '2025-26';
+const Q = [
+ '2025-06-30',
+ '2025-09-30',
+ '2025-12-31',
+ '2026-03-31'
+];
+
+const BOOK: TdsDeductee[] = [
+ {
+ clientId: 1,
+ customerId: 'C1',
+ name: 'Ramesh',
+ pan: 'ABCPR1234K',
+ dob: '1980-05-01',
+ branchCode: 'Head Office',
+ declarations: {},
+ accounts: [
+ {
+ id: 11,
+ accountNo: 'FD11',
+ depositTypeCode: 'depositAccountType.fixedDeposit',
+ clientId: 1,
+ credits: Q.map((date) => ({ date, amount: 15_000 })),
+ deductions: [{ date: Q[3], amount: 6_000 }]
+ },
+ {
+ id: 12,
+ accountNo: 'SB12',
+ depositTypeCode: 'depositAccountType.savingsDeposit',
+ clientId: 1,
+ credits: [{ date: Q[1], amount: 1_200 }],
+ deductions: []
+ }
+ ]
+ },
+ {
+ clientId: 4,
+ customerId: 'C4',
+ name: 'Kamala',
+ pan: 'ABCPK9012M',
+ dob: '1979-07-07',
+ branchCode: 'bhopal',
+ declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } },
+ accounts: [
+ {
+ id: 41,
+ accountNo: 'FD41',
+ depositTypeCode: 'depositAccountType.fixedDeposit',
+ clientId: 4,
+ credits: Q.map((date) => ({ date, amount: 20_000 })),
+ deductions: []
+ }
+ ]
+ }
+];
+
+function build() {
+ setTdsDeductees(BOOK);
+ const filter = {
+ fy: FY,
+ branchCode: '',
+ quarter: 0 as const,
+ band: '' as const,
+ search: '',
+ crossedOnly: false,
+ nearThresholdOnly: false
+ };
+ const rows = tdsInterestRows(filter);
+ return buildTdsInterestWorkbook({
+ fy: FY,
+ scopeLabel: 'All branches',
+ rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 },
+ statement: 'Form 26Q',
+ rows,
+ declaredRows: rows.filter((r) => r.hasDeclaration),
+ summary: tdsInterestSummary(filter),
+ statusOf: () => ({ label: 'status' })
+ });
+}
+
+describe('TDS register workbook', () => {
+ it('has the four sheets a reader expects, in order', () => {
+ expect(build().worksheets.map((w) => w.name)).toEqual([
+ 'Summary',
+ 'Customers',
+ 'Accounts',
+ 'Form 121 given'
+ ]);
+ });
+
+ it('lists one row per customer, with the figures as numbers and a live total', () => {
+ const ws = build().getWorksheet('Customers')!;
+ expect(ws.getRow(3).getCell(2).value).toBe('Customer name');
+ const at = [
+ 4,
+ 5
+ ].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!;
+ const ramesh = ws.getRow(at);
+ expect(ramesh.getCell(2).value).toBe('Ramesh');
+ expect(ramesh.getCell(7).value).toBe(60_000);
+ expect(ramesh.getCell(7).numFmt).toContain('#,##0.00');
+ expect(ramesh.getCell(11).value).toBe(6_000);
+ expect(ramesh.getCell(12).value).toBe(6_000);
+ expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` });
+ const total = ws.getRow(6);
+ expect(total.getCell(2).value).toBe('Total');
+ expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' });
+ });
+
+ it('lists every account behind the figures, saying which ones count', () => {
+ const ws = build().getWorksheet('Accounts')!;
+ const byAccount = new Map(
+ [
+ 4,
+ 5,
+ 6
+ ].map((n) => [
+ ws.getRow(n).getCell(4).value,
+ ws.getRow(n)
+ ])
+ );
+ expect([...byAccount.keys()].sort()).toEqual([
+ 'FD11',
+ 'FD41',
+ 'SB12'
+ ]);
+ expect(byAccount.get('SB12')!.getCell(6).value).toBe('No');
+ expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes');
+ expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date);
+ });
+
+ it('puts the declarants on their own sheet with the TDS the form saved', () => {
+ const ws = build().getWorksheet('Form 121 given')!;
+ const kamala = ws.getRow(4);
+ expect(kamala.getCell(2).value).toBe('Kamala');
+ expect(kamala.getCell(7).value).toBe('UIN0001');
+ expect(kamala.getCell(10).value).toBe(40_000);
+ expect(kamala.getCell(11).value).toBe(4_000);
+ });
+
+ it('writes a file Excel can open', async () => {
+ const buffer = await build().xlsx.writeBuffer();
+ expect(buffer.byteLength).toBeGreaterThan(5_000);
+ });
+});
diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts
new file mode 100644
index 000000000..f4f3c657b
--- /dev/null
+++ b/src/app/tds-reports/tds-interest-export.ts
@@ -0,0 +1,453 @@
+/**
+ * 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 * as ExcelJS from 'exceljs';
+
+import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute';
+
+/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */
+export interface TdsExportInput {
+ fy: string;
+ scopeLabel: string;
+ /** The financial-year rules, from Organization › TDS Configuration. */
+ rules: { general: number; senior: number; rate: number; rateNoPan: number } | null;
+ /** The quarterly return the declarants' gross-paid interest is reported in. */
+ statement: string;
+ rows: TdsInterestRow[];
+ declaredRows: TdsInterestRow[];
+ summary: TdsInterestSummary;
+ statusOf: (row: TdsInterestRow) => { label: string };
+}
+
+const MONEY = '"₹"#,##0.00';
+const DATE = 'dd-mmm-yyyy';
+const NAVY = 'FF1F4E79';
+const STRIPE = 'FFF3F6FA';
+const LINE = 'FFD0D7E2';
+const GREEN = 'FF15803D';
+const AMBER = 'FFB45309';
+
+const ACCOUNT_TYPE: Record = {
+ FD: 'Fixed deposit',
+ RD: 'Recurring deposit',
+ SB: 'Savings',
+ OTHER: 'Other'
+};
+
+/** A column: its heading, width and, for figures, the cell format. */
+interface Col {
+ header: string;
+ width: number;
+ fmt?: string;
+ /** Totalled in the closing row. */
+ sum?: boolean;
+}
+
+/**
+ * The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it:
+ * a one-page summary, one row per customer, one row per account, and the declarants on their own.
+ */
+export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook {
+ const wb = new ExcelJS.Workbook();
+ wb.creator = 'CBS Nexora';
+ wb.created = new Date();
+
+ summarySheet(wb, input);
+ customersSheet(wb, input);
+ accountsSheet(wb, input);
+ declarationsSheet(wb, input);
+ return wb;
+}
+
+// ─────────────────────────────── Sheets ───────────────────────────────
+
+function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
+ const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] });
+ ws.columns = [
+ { width: 46 },
+ { width: 22 }];
+ title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2);
+ ws.addRow([`Branch: ${input.scopeLabel}`]);
+ ws.addRow([
+ `Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`
+ ]);
+ ws.getRow(2).font = { color: { argb: 'FF555555' } };
+ ws.getRow(3).font = { color: { argb: 'FF555555' } };
+ ws.addRow([]);
+
+ const s = input.summary;
+ section(ws, 'Key figures');
+ const figures: [string, number, string?][] = [
+ [
+ 'Customers in this report',
+ s.deductees
+ ],
+ [
+ ' of whom have an FD / RD being tracked',
+ s.trackedCustomers
+ ],
+ [
+ 'FD / RD interest received this year',
+ s.applicableInterest,
+ MONEY
+ ],
+ [
+ 'Savings interest (shown only — no TDS)',
+ s.exemptInterest,
+ MONEY
+ ],
+ [
+ 'Customers over the TDS limit',
+ s.crossed
+ ],
+ [
+ 'TDS due by the rules',
+ s.totalTds,
+ MONEY
+ ],
+ [
+ 'TDS actually deducted from accounts',
+ s.actualTds,
+ MONEY
+ ],
+ [
+ 'Difference (due − deducted) — should be 0',
+ s.totalTds - s.actualTds,
+ MONEY
+ ],
+ [
+ 'Customers with Form 121 / 15G / 15H',
+ s.declarations
+ ],
+ [
+ 'TDS not deducted because of those forms',
+ s.tdsSaved,
+ MONEY
+ ],
+ [
+ 'Interest paid without TDS (report in the return)',
+ s.paidWithoutDeduction,
+ MONEY
+ ]
+ ];
+ for (const [
+ label,
+ value,
+ fmt
+ ] of figures) {
+ const r = ws.addRow([
+ label,
+ value
+ ]);
+ r.getCell(2).numFmt = fmt ?? '#,##0';
+ r.getCell(2).alignment = { horizontal: 'right' };
+ boxRow(r, 2);
+ }
+ // The control figure stands out when it is not zero.
+ const diffRow = ws.getRow(ws.rowCount - 3);
+ if (Math.round(s.totalTds - s.actualTds) !== 0) {
+ diffRow.font = { bold: true, color: { argb: AMBER } };
+ }
+ ws.addRow([]);
+
+ if (input.rules) {
+ section(ws, 'Rules for this year');
+ const rules: [string, string][] = [
+ [
+ 'TDS limit — below 60',
+ rupees(input.rules.general)
+ ],
+ [
+ 'TDS limit — senior citizen (60+)',
+ rupees(input.rules.senior)
+ ],
+ [
+ 'Rate with PAN',
+ `${input.rules.rate}%`
+ ],
+ [
+ 'Rate without PAN',
+ `${input.rules.rateNoPan}%`
+ ]
+ ];
+ for (const [
+ label,
+ value
+ ] of rules) {
+ const r = ws.addRow([
+ label,
+ value
+ ]);
+ r.getCell(2).alignment = { horizontal: 'right' };
+ boxRow(r, 2);
+ }
+ ws.addRow([]);
+ }
+
+ section(ws, 'How to read this workbook');
+ const notes = [
+ 'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.',
+ "A savings account's own interest is shown for information and never attracts TDS.",
+ "Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.",
+ 'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.',
+ 'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.',
+ 'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.'
+ ];
+ for (const n of notes) {
+ const r = ws.addRow([`• ${n}`]);
+ ws.mergeCells(r.number, 1, r.number, 2);
+ r.alignment = { wrapText: true, vertical: 'top' };
+ r.height = 30;
+ }
+}
+
+function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
+ const cols: Col[] = [
+ { header: 'S.No', width: 6 },
+ { header: 'Customer name', width: 26 },
+ { header: 'Customer ID', width: 13 },
+ { header: 'PAN', width: 13 },
+ { header: 'Branch', width: 14 },
+ { header: 'Age group', width: 12 },
+ { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
+ { header: 'TDS limit (₹)', width: 13, fmt: MONEY },
+ { header: 'Over the limit?', width: 10 },
+ { header: 'Rate', width: 7 },
+ { header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true },
+ { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
+ { header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true },
+ { header: 'Form 121 given?', width: 10 },
+ { header: 'Status', width: 42 }
+ ];
+ const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols);
+
+ input.rows.forEach((r, i) => {
+ const line = ws.addRow([
+ i + 1,
+ r.deductee.name,
+ r.deductee.customerId,
+ r.deductee.pan || 'Not on record',
+ r.branchCode,
+ r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60',
+ r.tdsApplicableInterest,
+ r.threshold,
+ r.crossed ? 'Yes' : 'No',
+ `${Math.round(r.rateApplied * 100)}%`,
+ r.schedule.totalTds,
+ r.actualTds,
+ null,
+ r.hasDeclaration ? 'Yes' : 'No',
+ input.statusOf(r).label
+ ]);
+ // Due − deducted as a formula, so an edited figure keeps the check honest.
+ line.getCell(13).value = { formula: `K${line.number}-L${line.number}` };
+ styleDataRow(line, cols, i);
+ if (!r.deductee.pan) {
+ line.getCell(4).font = { color: { argb: AMBER } };
+ }
+ if (r.crossed) {
+ line.getCell(9).font = { bold: true, color: { argb: AMBER } };
+ }
+ if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) {
+ line.getCell(13).font = { bold: true, color: { argb: AMBER } };
+ }
+ });
+ totals(ws, cols, input.rows.length);
+}
+
+function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
+ const cols: Col[] = [
+ { header: 'S.No', width: 6 },
+ { header: 'Customer name', width: 26 },
+ { header: 'Customer ID', width: 13 },
+ { header: 'Account no.', width: 19 },
+ { header: 'Account type', width: 17 },
+ { header: 'Counted for TDS?', width: 10 },
+ { header: 'Why', width: 46 },
+ { header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true },
+ { header: 'Credits', width: 8 },
+ { header: 'Last credited', width: 13, fmt: DATE },
+ { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true },
+ { header: 'Account held by', width: 22 }
+ ];
+ const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols);
+
+ let n = 0;
+ for (const r of input.rows) {
+ for (const a of r.accounts) {
+ const line = ws.addRow([
+ n + 1,
+ r.deductee.name,
+ r.deductee.customerId,
+ a.accountNo,
+ ACCOUNT_TYPE[a.type],
+ a.tracked ? 'Yes' : 'No',
+ a.reason,
+ a.interest,
+ a.postings,
+ a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—',
+ a.tdsDeducted,
+ a.holderName ?? 'Same customer'
+ ]);
+ styleDataRow(line, cols, n);
+ if (a.tracked) {
+ line.getCell(6).font = { bold: true, color: { argb: GREEN } };
+ }
+ n++;
+ }
+ }
+ totals(ws, cols, n);
+}
+
+function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void {
+ const cols: Col[] = [
+ { header: 'S.No', width: 6 },
+ { header: 'Customer name', width: 26 },
+ { header: 'Customer ID', width: 13 },
+ { header: 'PAN', width: 13 },
+ { header: 'Branch', width: 14 },
+ { header: 'Form', width: 10 },
+ { header: 'UIN', width: 18 },
+ { header: 'Accepted on', width: 13, fmt: DATE },
+ { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true },
+ { header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true },
+ { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true },
+ { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true }
+ ];
+ const ws = table(
+ wb,
+ 'Form 121 given',
+ `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`,
+ cols
+ );
+
+ input.declaredRows.forEach((r, i) => {
+ const line = ws.addRow([
+ i + 1,
+ r.deductee.name,
+ r.deductee.customerId,
+ r.deductee.pan || 'Not on record',
+ r.branchCode,
+ `Form ${r.form}`,
+ r.declarationUin ?? 'Not allotted',
+ r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—',
+ r.tdsApplicableInterest,
+ r.paidWithoutDeduction,
+ r.tdsSaved,
+ r.actualTds
+ ]);
+ styleDataRow(line, cols, i);
+ if (!r.declarationUin) {
+ line.getCell(7).font = { color: { argb: AMBER } };
+ }
+ });
+ if (input.declaredRows.length === 0) {
+ const r = ws.addRow([
+ '',
+ 'No customer has a declaration on record for this year.'
+ ]);
+ r.font = { italic: true, color: { argb: 'FF777777' } };
+ } else {
+ totals(ws, cols, input.declaredRows.length);
+ }
+}
+
+// ─────────────────────────────── Styling ───────────────────────────────
+
+/** A sheet with a title, a styled header row, frozen headings and a filter. */
+function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet {
+ const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] });
+ ws.columns = cols.map((c) => ({ width: c.width }));
+ title(ws, heading, cols.length);
+ ws.addRow([]);
+ const head = ws.addRow(cols.map((c) => c.header));
+ head.height = 34;
+ head.eachCell((cell) => {
+ cell.font = { bold: true, color: { argb: 'FFFFFFFF' } };
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
+ cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true };
+ cell.border = border();
+ });
+ ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } };
+ return ws;
+}
+
+function title(ws: ExcelJS.Worksheet, text: string, span: number): void {
+ const r = ws.addRow([text]);
+ ws.mergeCells(r.number, 1, r.number, span);
+ r.font = { bold: true, size: 14, color: { argb: NAVY } };
+ r.height = 24;
+}
+
+function section(ws: ExcelJS.Worksheet, text: string): void {
+ const r = ws.addRow([text]);
+ r.font = { bold: true, color: { argb: 'FFFFFFFF' } };
+ r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
+ r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } };
+}
+
+function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void {
+ cols.forEach((c, i) => {
+ const cell = row.getCell(i + 1);
+ cell.border = border();
+ cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 };
+ if (c.fmt) {
+ cell.numFmt = c.fmt;
+ cell.alignment = { ...cell.alignment, horizontal: 'right' };
+ }
+ if (index % 2 === 1) {
+ cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } };
+ }
+ });
+}
+
+/** A closing row that totals the money columns with live SUM formulas. */
+function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void {
+ if (count === 0) {
+ return;
+ }
+ const first = 4;
+ const last = first + count - 1;
+ const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null)));
+ cols.forEach((c, i) => {
+ const cell = r.getCell(i + 1);
+ if (c.sum) {
+ const letter = ws.getColumn(i + 1).letter;
+ cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` };
+ cell.numFmt = c.fmt ?? '#,##0';
+ cell.alignment = { horizontal: 'right' };
+ }
+ cell.font = { bold: true };
+ cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } };
+ });
+}
+
+function boxRow(row: ExcelJS.Row, span: number): void {
+ for (let i = 1; i <= span; i++) {
+ row.getCell(i).border = border();
+ }
+}
+
+function border(): Partial {
+ const side = { style: 'thin' as const, color: { argb: LINE } };
+ return { top: side, left: side, bottom: side, right: side };
+}
+
+function rupees(n: number): string {
+ return `₹${Math.round(n).toLocaleString('en-IN')}`;
+}
+
+/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */
+function isoToDate(iso: string): Date {
+ const [
+ y,
+ m,
+ d
+ ] = iso.slice(0, 10).split('-').map(Number);
+ return new Date(Date.UTC(y, m - 1, d));
+}
diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html
index 27cd9f12e..70117543e 100644
--- a/src/app/tds-reports/tds-interest-report.component.html
+++ b/src/app/tds-reports/tds-interest-report.component.html
@@ -13,68 +13,132 @@
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 the interest from
+ fixed and recurring deposits that reaches the customer's savings account
+ is tracked — counted on the day it arrives. The savings account's own interest is shown for information but
+ never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to
+ savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is
+ not taxed.
+
+ @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 on interest received after the form was accepted.
+
+
+ TDS is debited from the savings account at the moment the FD/RD interest that takes the
+ customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited.
+ The TDS column shows what is due by these rules and what was actually deducted — the two
+ should match.
+
+
+
+ }
+
{{ summary.deductees | number }}
-
Deductees
+
Customers · {{ summary.trackedCustomers | number }} with an FD/RD being tracked
- Paying TDS ({{ payingRows.length }})
+ TDS deducted / due ({{ payingRows.length }})
+
+
+ Form 121 / 15G / 15H given ({{ declaredRows.length }})
- Not Paying TDS ({{ notPayingRows.length }})
+ No TDS ({{ notPayingRows.length }})
+ @if (selectedTab === 'declared') {
+
+
+
+ Customers who gave a declaration for FY {{ filter.fy }}. TDS not deducted is the tax that would
+ have been taken without the form; Paid without TDS is the interest received after the form was
+ accepted — it must still be reported in the {{ statute.statement }} return. Interest received before the form
+ stays taxable, and any TDS taken on it is shown as deducted before the form.
+
+
+ }
+
-
Customer · PAN
+
Customer
Branch
-
Form · Band
-
Q1
-
Q2
-
Q3
-
Q4
-
Year total
-
Threshold
-
Crossed
-
TDS
-
Saved
+ @if (selectedTab === 'declared') {
+
Form · UIN · accepted on
+
FD / RD interest received · paid without TDS
+
Limit
+
Status
+
TDS not deducted · deducted before the form
+ } @else {
+
Accounts
+
FD / RD interest received this year
+
Limit
+
Status
+
TDS
+ }
@for (row of pagedRows; track trackRow($index, row)) {
-
+
{{ row.deductee.name }}
- {{ row.deductee.customerId }} ·
+ ID {{ row.deductee.customerId }} ·
@if (row.panOnRecord) {
- {{ row.deductee.pan }}
+ PAN {{ row.deductee.pan }}
} @else {
no PAN
}
+ @if (row.band === 'SENIOR') {
+ Senior 60+
+ }
- @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 (FD/RD: received in savings)
+
Credits
+
Last credited
+
TDS deducted
- @for (line of row.schedule.lines; track line.quarter) {
-
-
- {{ line.label }}
- @if (line.thresholdCrossedHere) {
- crossed here
- }
- @if (line.declarationActive) {
- Form 121 active
+ @for (a of row.accounts; track a.accountNo) {
+
+
+ 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 if (selectedTab === 'declared') {
+ No customer has a Form 121 / 15G / 15H on record for this year. Record one from the customer's Form
+ 121 tab.
+ } @else {
+ No customers match these filters.
+ }
}
@@ -352,8 +523,7 @@
Showing {{ rangeStart | number }}–{{ rangeEnd | number }} of
- {{ visibleRows.length | number }}
- {{ selectedTab === 'paying' ? 'deductees paying TDS' : 'deductees not paying TDS' }}
+ {{ visibleRows.length | number }} customers
- Reconciled.
- ₹{{ 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.
+ This report is for Head Office. TDS is paid to the Government by Head Office for the whole
+ bank, under one TAN. Your branch's customers and the TDS deducted from them are on
+ Reports › Deposit Interest & TDS.
} @else {
-
-
-
- Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.
- Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the
- return.
+ @if (loadError) {
+
+
+
{{ loadError }}
-
- }
+ }
- @if (summary.overdueChallans > 0) {
-
-
-
- {{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue.
- Interest runs at 1.5% for every month or part of a month until the tax reaches the Government.
+
+ @if (showForm) {
+
+
Record a challan — FY {{ fy }}
+
+
+
+
+
+
+
+
+
+
+ Saving posts Dr TDS payable / Cr the account paid from at Head Office, dated the day it was
+ paid.
+
- @if (row.overdue) {
- Overdue
- } @else if (row.late) {
- Late by {{ row.delayMonths }}m
- } @else if (row.depositedOn) {
- On time
- } @else {
- Within due date
- }
-
- Deposited with Government
- ₹{{ summary.totalDeposited | number: '1.0-0' }}
-
-
- Still within due date
- ₹{{ summary.pendingDeposit | number: '1.0-0' }}
-
-
- Difference — must be zero
- ₹{{ summary.difference | number: '1.0-0' }}
-
-
- @if (summary.defaultInterest > 0) {
-
- Interest payable by the bank for late deposit
- ₹{{ summary.defaultInterest | number: '1.0-0' }}
+ @if (summary) {
+
+ @if (reconciled) {
+
+
+
+ Reconciled.
+ ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{
+ summary.totalDeposited | number: '1.0-0'
+ }}
+ paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date.
+
+
+ } @else {
+
+
+
+ Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted.
+ Tax deducted must equal tax paid plus tax still within its due date.
+ @if (summary.overdueAmount > 0) {
+ ₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid.
+ }
+
+ By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but ₹{{
+ summary.totalDeducted | number: '1.0-0'
+ }}
+ was actually deducted from accounts. Open Deposit Interest & TDS to see which customers differ.
+
+ @if (row.overdue) {
+ Overdue
+ } @else if (row.late) {
+ Paid late by {{ row.delayMonths }}m
+ } @else if (row.outstanding) {
+ Within due date
+ } @else {
+ Paid on time
+ }
+
+
+ {{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }}
+
+
+ }
+
+
+
+
+ Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April.
+ Interest runs at 1.5% for every month or part of a month it is late.
+
-
- 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.
-
-
+
+
+ @if (summary) {
+
+
+ Part B — {{ statute.statement }} year-end position
+
+
+
+ Deductees (customers with an FD/RD)
+ {{ summary.deductees | number }}
+
+
+ Total interest paid on time deposits
+ ₹{{ summary.totalInterestPaid | number: '1.0-0' }}
+
+ Paid to Government
+ ₹{{ summary.totalDeposited | number: '1.0-0' }}
+
+
+ Still within due date
+ ₹{{ summary.pendingDeposit | number: '1.0-0' }}
+
+
+ Difference — must be zero
+ ₹{{ summary.difference | number: '1.0-0' }}
+
+
+ @if (summary.defaultInterest > 0) {
+
+ Interest payable by the bank for late deposit
+ ₹{{ summary.defaultInterest | number: '1.0-0' }}
+
+ }
+
+
+ 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.
+
+
+ }
+ }
diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts
index 5a1b9ce66..ee29ee6b5 100644
--- a/src/app/tds-reports/tds-remittance-report.component.ts
+++ b/src/app/tds-reports/tds-remittance-report.component.ts
@@ -6,17 +6,25 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
-import { Component, inject } from '@angular/core';
+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 { forkJoin } from 'rxjs';
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 { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data';
+import { NewTdsChallan, TdsReportsService } from './tds-reports.service';
import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute';
+/**
+ * Tax deducted against tax remitted, month by month, for Head Office.
+ *
+ * The deducted side is the withhold-tax postings on deposits; the remitted side is the challans
+ * entered here. Both are records — nothing on this page is worked out from a rate.
+ */
@Component({
selector: 'mifosx-tds-remittance-report',
templateUrl: './tds-remittance-report.component.html',
@@ -27,21 +35,83 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro
FormsModule
]
})
-export class TdsRemittanceReportComponent {
+export class TdsRemittanceReportComponent implements OnInit {
private scope = inject(BranchScopeService);
+ private tdsService = inject(TdsReportsService);
- readonly financialYears = TDS_FYS;
+ /** Filled by the route resolver from Organization › TDS Configuration. */
+ readonly financialYears = tdsFys();
readonly quarterLabel = QUARTER_LABEL;
- readonly asAt = TDS_AS_AT;
- fy = TDS_FYS[0];
+ fy = tdsFys()[0] ?? '';
exporting = false;
+ loading = false;
+ loadError = '';
+
+ /** The business date the platform is on — what "overdue" is judged against. */
+ asAt = '';
+ private deducted: TdsDeductedMonth[] = [];
+ challans: TdsChallan[] = [];
+ rows: TdsRemittanceRow[] = [];
+ summary: Tds26qSummary | null = null;
+
+ paidFromOptions: { id: number; glCode: string; name: string }[] = [];
+ showForm = false;
+ saving = false;
+ formError = '';
+ form: NewTdsChallan = this.blankForm();
+
+ /** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */
+ get isHeadOffice(): boolean {
+ return this.scope.isHeadOffice;
+ }
+
+ ngOnInit(): void {
+ if (!this.isHeadOffice || !this.fy) {
+ return;
+ }
+ this.load();
+ this.tdsService.getChallanTemplate().subscribe({
+ next: (t) => (this.paidFromOptions = t?.paidFromOptions || []),
+ error: () => (this.paidFromOptions = [])
+ });
+ }
+
+ load(): void {
+ this.loading = true;
+ this.loadError = '';
+ const openingYear = Number(this.fy.slice(0, 4));
+ forkJoin({
+ deducted: this.tdsService.getDeducted(this.fy),
+ challans: this.tdsService.getChallans(this.fy),
+ // Part B summarises the year per customer, so the interest register is loaded too.
+ deductees: this.tdsService.getDepositInterest(openingYear)
+ }).subscribe({
+ next: ({ deducted, challans, deductees }) => {
+ this.loading = false;
+ this.asAt = deducted.asAt;
+ this.deducted = deducted.months;
+ this.challans = challans;
+ setTdsDeductees(deductees);
+ this.recompute();
+ },
+ error: (err: any) => {
+ this.loading = false;
+ this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.';
+ }
+ });
+ }
+
+ private recompute(): void {
+ this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt);
+ this.summary = tds26qSummary(this.fy, this.rows, this.asAt);
+ }
+
+ onYearChanged(): void {
+ this.showForm = false;
+ this.load();
+ }
- /**
- * 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 {
@@ -51,53 +121,95 @@ export class TdsRemittanceReportComponent {
};
}
- 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;
+ return !!this.summary && Math.abs(this.summary.difference) < 1;
}
- get totalDefaultInterest(): number {
- return this.summary.defaultInterest;
+ /** Opens the challan form, pre-filled for a month that still has tax outstanding. */
+ openForm(row?: TdsRemittanceRow): void {
+ this.form = this.blankForm();
+ if (row) {
+ this.form.month = row.month;
+ this.form.amount = row.outstanding;
+ }
+ this.formError = '';
+ this.showForm = true;
+ }
+
+ saveChallan(): void {
+ this.formError = '';
+ const f = this.form;
+ if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) {
+ this.formError = 'Fill in every field marked *.';
+ return;
+ }
+ this.saving = true;
+ this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({
+ next: (challans) => {
+ this.saving = false;
+ this.showForm = false;
+ this.challans = challans;
+ this.recompute();
+ },
+ error: (err: any) => {
+ this.saving = false;
+ this.formError =
+ err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
+ }
+ });
+ }
+
+ reverseChallan(c: TdsChallan): void {
+ if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) {
+ return;
+ }
+ this.tdsService.reverseChallan(c.id).subscribe({
+ next: (challans) => {
+ this.challans = challans;
+ this.recompute();
+ },
+ error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.')
+ });
+ }
+
+ private blankForm(): NewTdsChallan {
+ return {
+ fy: this.fy,
+ month: '',
+ amount: 0,
+ challanNo: '',
+ bsrCode: '',
+ depositedOn: '',
+ paidFromGlAccountId: 0,
+ remarks: ''
+ };
}
trackRow(_: number, row: TdsRemittanceRow): string {
- return `${row.fy}-${row.quarter}`;
+ return row.month;
}
async exportWorkbook(): Promise {
+ const s = this.summary;
+ if (!s) {
+ return;
+ }
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([`Bank as a whole · as at ${this.asAt}`]);
challan.addRow([]);
challan.addRow([
'Quarter',
'Deduction month',
- 'Amount deducted',
+ 'Deducted',
'Due date',
- 'Deposited on',
- 'Challan no.',
- 'BSR code',
+ 'Deposited',
+ 'Challans',
+ 'Outstanding',
'Status',
'Delay (months)',
'Late-deposit interest',
@@ -107,12 +219,12 @@ export class TdsRemittanceReportComponent {
challan.addRow([
`Q${r.quarter}`,
r.month,
- r.amount,
+ r.deducted,
r.dueDate,
- r.depositedOn ?? 'PENDING',
- r.challanNo ?? '',
- r.bsrCode ?? '',
- r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date',
+ r.deposited,
+ r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '),
+ r.outstanding,
+ r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited',
r.delayMonths || '',
r.defaultInterest || '',
r.returnDueDate
@@ -151,7 +263,11 @@ export class TdsRemittanceReportComponent {
s.tdsAtHigherRate
],
[
- 'Total TDS deducted',
+ 'TDS due by the rules',
+ s.calculatedTds
+ ],
+ [
+ 'Total TDS actually deducted',
s.totalDeducted
],
[
@@ -162,6 +278,10 @@ export class TdsRemittanceReportComponent {
'Still within due date',
s.pendingDeposit
],
+ [
+ 'Overdue',
+ s.overdueAmount
+ ],
[
'DIFFERENCE (must be zero)',
s.difference
@@ -169,14 +289,6 @@ export class TdsRemittanceReportComponent {
[
'Interest payable for late deposit',
s.defaultInterest
- ],
- [
- 'Challans deposited late',
- s.lateChallans
- ],
- [
- 'Challans overdue',
- s.overdueChallans
]
];
for (const line of lines) {
diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts
index f5c639c6e..4f753f880 100644
--- a/src/app/tds-reports/tds-reports-data.ts
+++ b/src/app/tds-reports/tds-reports-data.ts
@@ -7,33 +7,33 @@
*/
/**
- * Deductees and their deposit interest, for the two TDS reports.
+ * Deductees, their deposit interest and the challans, 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.
+ * Everything here is what the platform returned. The challans used to be generated from the
+ * computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter
+ * deliberately late - so the remittance report reconciled against figures nobody had entered.
*/
-import { GL_BRANCHES } from '../gl-reports/gl-reports-data';
import {
+ configuredFys,
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'
-];
+/** The years the reports can show: every year configured on Organization › TDS Configuration. */
+export function tdsFys(): string[] {
+ return configuredFys();
+}
-/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */
-export const TDS_AS_AT = '2026-08-01';
+/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */
+export interface TdsReportAccount extends TdsAccountInput {
+ /** TDS the platform actually deducted from this account in the year. */
+ deductions: InterestCredit[];
+ /** The account's own holder, when the tax is deducted against somebody else (minor, joint). */
+ holderClientId?: number;
+ holderName?: string;
+}
export interface TdsDeductee {
clientId: number;
@@ -44,115 +44,35 @@ export interface TdsDeductee {
branchCode: string;
/** Declarations on record, keyed by financial year. */
declarations: Record;
- accounts: TdsAccountInput[];
+ accounts: TdsReportAccount[];
}
-/** One month's remittance of deducted tax to the Government. */
+/** One challan Head Office entered: tax remitted to the Government. */
export interface TdsChallan {
+ id: number;
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;
+ challanNo: string;
+ bsrCode: string;
+ depositedOn: string;
+ paidFrom: string;
+ remarks: string | null;
+ reversed: boolean;
}
-/** 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(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()}`;
+/** Tax the platform withheld in one month. */
+export interface TdsDeductedMonth {
+ month: string;
+ amount: number;
+ postings: number;
}
/**
* The deductees the reports run on, loaded from the platform.
*
- * This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all.
- * The computation over them is the real section 194A calculation, so the figures looked like a
- * filing and were not one: not a rupee of it came from the bank's own books.
- *
- * It is set once by whoever loads the screen and read by the pure computation below, which is why
+ * It is set once by whoever loads the screen and read by the pure computation, which is why
* this is a module-level holder rather than a service the compute would have to inject.
*/
let liveDeductees: TdsDeductee[] = [];
@@ -171,7 +91,6 @@ export function tdsDeductees(): TdsDeductee[] {
return liveDeductees;
}
-
/**
* Statutory date by which tax deducted in a given month must be deposited.
*
@@ -189,63 +108,3 @@ export function depositDueDate(month: string): string {
const nextM = m === 12 ? 1 : m + 1;
return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`;
}
-
-let challanCache: Record | 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>): Record {
- if (challanCache) {
- return challanCache;
- }
- const out: Record = {};
-
- for (const fy of TDS_FYS) {
- const y = Number(fy.slice(0, 4));
- const months: Record = {
- 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;
-}
diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts
index b128afe9c..fd8fd919d 100644
--- a/src/app/tds-reports/tds-reports.compute.spec.ts
+++ b/src/app/tds-reports/tds-reports.compute.spec.ts
@@ -6,12 +6,10 @@
* 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 { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture';
+import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data';
import {
- NEAR_THRESHOLD_BAND,
TdsReportFilter,
- deductedByQuarter,
tds26qSummary,
tdsInterestRows,
tdsInterestRowsForFy,
@@ -19,12 +17,135 @@ import {
tdsRemittanceRows
} from './tds-reports.compute';
-/** The complete year in the seed — the one a return would be filed for. */
-const CLOSED_FY = '2025-26';
+installTdsConfigFixture();
+
+/**
+ * A small book with every case the reports must handle once, so each figure below can be worked
+ * out by hand. The reports used to run on sixty-four generated customers; the tests asserted
+ * shapes ("some row crossed") because nobody could know the numbers.
+ */
+const FY = '2025-26';
+const Q = [
+ '2025-06-30',
+ '2025-09-30',
+ '2025-12-31',
+ '2026-03-31'
+];
+
+const fd = (id: number, amounts: number[], extra: object = {}) => ({
+ id,
+ accountNo: `FD${id}`,
+ depositTypeCode: 'depositAccountType.fixedDeposit',
+ credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })),
+ deductions: [] as { date: string; amount: number }[],
+ ...extra
+});
+
+const sb = (id: number, amount: number) => ({
+ id,
+ accountNo: `SB${id}`,
+ depositTypeCode: 'depositAccountType.savingsDeposit',
+ credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }],
+ deductions: [] as { date: string; amount: number }[]
+});
+
+function person(
+ clientId: number,
+ name: string,
+ dob: string,
+ pan: string,
+ branch: string,
+ accounts: any[],
+ declarations = {}
+): TdsDeductee {
+ return {
+ clientId,
+ customerId: `C${clientId}`,
+ name,
+ pan,
+ dob,
+ branchCode: branch,
+ declarations,
+ accounts: accounts.map((a) => ({ clientId, ...a }))
+ };
+}
+
+const BOOK: TdsDeductee[] = [
+ // Over the limit in Q4, plus a savings account that must stay outside the figure.
+ person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [
+ {
+ ...fd(
+ 11,
+ [
+ 15_000,
+ 15_000,
+ 15_000,
+ 15_000
+ ]
+ ),
+ deductions: [{ date: Q[3], amount: 6_000 }]
+ },
+ sb(12, 1_200)
+ ]),
+ // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little.
+ person(2, 'Sunita', '1955-01-01', '', 'bhopal', [
+ {
+ ...fd(
+ 21,
+ [
+ 30_000,
+ 30_000,
+ 30_000,
+ 30_000
+ ]
+ ),
+ deductions: [{ date: Q[3], amount: 20_000 }]
+ }
+ ]),
+ // ₹6,000 short of the limit: the customer to ask for a declaration.
+ person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [
+ fd(
+ 31,
+ [
+ 22_000,
+ 22_000
+ ]
+ )
+ ]),
+ // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross.
+ person(
+ 4,
+ 'Kamala',
+ '1979-07-07',
+ 'ABCPK9012M',
+ 'Head Office',
+ [
+ fd(
+ 41,
+ [
+ 20_000,
+ 20_000,
+ 20_000,
+ 20_000
+ ]
+ )
+ ],
+ {
+ [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' }
+ }
+ ),
+ // A minor's deposit, already listed under the guardian by the platform.
+ person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [
+ { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' }]),
+ // Savings only, no PAN: never deducted, so never counted as a no-PAN problem.
+ person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)])
+];
+
+setTdsDeductees(BOOK);
function filter(overrides: Partial = {}): TdsReportFilter {
return {
- fy: CLOSED_FY,
+ fy: FY,
branchCode: '',
quarter: 0,
band: '',
@@ -35,115 +156,94 @@ function filter(overrides: Partial = {}): TdsReportFilter {
};
}
-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);
+const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!;
+
+describe('which accounts are tracked', () => {
+ it('counts the FD and leaves the savings account out, saying why', () => {
+ const r = row('Ramesh');
+ expect(r.tdsApplicableInterest).toBe(60_000);
+ expect(r.exemptInterest).toBe(1_200);
+ expect(r.trackedAccounts).toBe(1);
+ const savings = r.accounts.find((a) => a.type === 'SB')!;
+ expect(savings.tracked).toBe(false);
+ expect(savings.reason).toContain('Savings');
+ expect(r.accounts[0].type).toBe('FD');
});
- 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('shows whose account a deposit is when the tax falls on the guardian', () => {
+ const line = row('Ganpat').accounts[0];
+ expect(line.tracked).toBe(true);
+ expect(line.holderName).toBe('Baby Ganpat');
});
- 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);
- }
+ it('marks a savings-only customer as not tracked', () => {
+ expect(row('Shobha').trackedAccounts).toBe(0);
});
});
-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);
+describe('the threshold and the rate', () => {
+ it('deducts on the whole year once the general limit is crossed', () => {
+ const r = row('Ramesh');
+ expect(r.threshold).toBe(50_000);
+ expect(r.crossedInQuarter).toBe(4);
+ expect(r.schedule.totalTds).toBe(6_000);
});
- 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);
- }
- }
+ it('gives a senior the higher limit, and a missing PAN the higher rate', () => {
+ const r = row('Sunita');
+ expect(r.band).toBe('SENIOR');
+ expect(r.threshold).toBe(100_000);
+ expect(r.rateApplied).toBe(0.2);
+ expect(r.schedule.totalTds).toBe(24_000);
+ });
+
+ it('flags a customer just under the limit', () => {
+ const r = row('Vitthal');
+ expect(r.crossed).toBe(false);
+ expect(r.nearThreshold).toBe(true);
+ expect(r.headroom).toBe(6_000);
+ expect(r.schedule.totalTds).toBe(0);
});
});
-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);
+describe('a mid-year declaration', () => {
+ it('taxes only the interest credited before it was accepted', () => {
+ const r = row('Kamala');
+ expect(r.hasDeclaration).toBe(true);
+ // ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit.
+ expect(r.schedule.totalTds).toBe(4_000);
+ expect(r.tdsSaved).toBe(4_000);
+ expect(r.paidWithoutDeduction).toBe(40_000);
+ });
+});
- 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);
- }
+describe('due against actually deducted', () => {
+ it('agrees where the platform deducted the right amount', () => {
+ expect(row('Ramesh').actualTds).toBe(6_000);
});
- it('leaves the savings totals out of the summary base', () => {
+ it('shows the gap where it did not', () => {
+ const r = row('Sunita');
+ expect(r.actualTds).toBe(20_000);
+ expect(r.schedule.totalTds - r.actualTds).toBe(4_000);
+ });
+
+ it('totals both in the summary', () => {
const s = tdsInterestSummary(filter());
- expect(s.exemptInterest).toBeGreaterThan(0);
- expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0));
+ expect(s.totalTds).toBe(6_000 + 24_000 + 4_000);
+ expect(s.actualTds).toBe(26_000);
});
});
-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);
- }
+describe('the summary', () => {
+ it('counts a missing PAN only where a deposit is being tracked', () => {
+ const s = tdsInterestSummary(filter());
+ expect(s.noPan).toBe(1);
+ expect(s.trackedCustomers).toBe(5);
+ expect(s.exemptInterest).toBe(2_100);
});
- it('and across the summary', () => {
+ it('adds the quarters up to the year', () => {
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);
@@ -151,151 +251,128 @@ describe('quarter figures add up to the year', () => {
});
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('narrows to a branch by office name, whatever its case, without re-aggregating', () => {
+ const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' }));
+ expect(scoped.map((r) => r.deductee.name).sort()).toEqual([
+ 'Sunita',
+ 'Vitthal'
+ ]);
+ expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000);
});
- 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('finds the customers near the limit and those over it', () => {
+ expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']);
+ expect(
+ tdsInterestRows(filter({ crossedOnly: true }))
+ .map((r) => r.deductee.name)
+ .sort()
+ ).toEqual([
+ 'Kamala',
+ 'Ramesh',
+ 'Sunita'
+ ]);
});
- 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);
+ it('searches on name, customer ID and PAN', () => {
+ expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1);
+ expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala');
});
});
-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();
- }
+describe('remittance', () => {
+ const challan = (over: Partial): TdsChallan => ({
+ id: 1,
+ fy: FY,
+ month: '2026-03',
+ amount: 0,
+ challanNo: '00001',
+ bsrCode: '0510308',
+ depositedOn: '2026-04-20',
+ paidFrom: '',
+ remarks: null,
+ reversed: false,
+ ...over
});
- 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', () => {
+ it('pays 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('marks a month paid in full and on time', () => {
+ const [r] = tdsRemittanceRows(
+ FY,
+ [{ month: '2026-03', amount: 26_000, postings: 2 }],
+ [challan({ amount: 26_000 })],
+ '2026-06-01'
+ );
+ expect(r.quarter).toBe(4);
+ expect(r.outstanding).toBe(0);
+ expect(r.late).toBe(false);
+ expect(r.overdue).toBe(false);
+ expect(r.defaultInterest).toBe(0);
});
- 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);
- }
+ it('charges interest on a late challan', () => {
+ const [r] = tdsRemittanceRows(
+ FY,
+ [{ month: '2026-03', amount: 26_000, postings: 2 }],
+ [challan({ amount: 26_000, depositedOn: '2026-05-02' })],
+ '2026-06-01'
+ );
+ expect(r.late).toBe(true);
+ expect(r.delayMonths).toBeGreaterThan(0);
+ expect(r.defaultInterest).toBeGreaterThan(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.
+ it('flags an unpaid month past its due date, and not one still within it', () => {
+ const rows = tdsRemittanceRows(
+ FY,
+ [
+ { month: '2025-12', amount: 5_000, postings: 1 },
+ { month: '2026-03', amount: 7_000, postings: 1 }
+ ],
+ [],
+ '2026-04-10'
+ );
+ const dec = rows.find((r) => r.month === '2025-12')!;
+ const mar = rows.find((r) => r.month === '2026-03')!;
+ expect(dec.overdue).toBe(true);
+ expect(dec.defaultInterest).toBeGreaterThan(0);
+ expect(mar.overdue).toBe(false);
+ expect(mar.outstanding).toBe(7_000);
+ });
+
+ it('ignores a reversed challan', () => {
+ const [r] = tdsRemittanceRows(
+ FY,
+ [{ month: '2026-03', amount: 26_000, postings: 2 }],
+ [challan({ amount: 26_000, reversed: true })],
+ '2026-04-10'
+ );
+ expect(r.deposited).toBe(0);
+ expect(r.outstanding).toBe(26_000);
+ });
+
+ it('reconciles deducted against paid plus still-within-due', () => {
+ const asAt = '2026-04-10';
+ const rows = tdsRemittanceRows(
+ FY,
+ [
+ { month: '2025-12', amount: 5_000, postings: 1 },
+ { month: '2026-03', amount: 21_000, postings: 1 }
+ ],
+ [challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })],
+ asAt
+ );
+ const s = tds26qSummary(FY, rows, asAt);
+ expect(s.totalDeducted).toBe(26_000);
+ expect(s.totalDeposited).toBe(5_000);
+ expect(s.pendingDeposit).toBe(21_000);
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);
- }
+ // The year's figures come from the same book as the interest report.
+ expect(s.calculatedTds).toBe(34_000);
+ expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000);
});
});
diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts
index afaa47d2a..547d0a933 100644
--- a/src/app/tds-reports/tds-reports.compute.ts
+++ b/src/app/tds-reports/tds-reports.compute.ts
@@ -17,7 +17,6 @@
import {
DeclarationForm,
- FY_TAX_CONFIG,
Quarter,
TdsMode,
TdsSchedule,
@@ -27,6 +26,7 @@ import {
declarationFormFor,
formKindFor,
fyOf,
+ fyTaxConfigVersion,
isSeniorInFy,
isTdsApplicable,
isWellFormedPan,
@@ -35,7 +35,15 @@ import {
tdsDeducteeOf,
tdsModeFor
} from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
-import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data';
+import {
+ TdsChallan,
+ TdsDeductedMonth,
+ TdsDeductee,
+ TdsReportAccount,
+ depositDueDate,
+ tdsDeductees,
+ tdsDeducteesVersion
+} from './tds-reports-data';
// ─────────────────────────────── Filtering ───────────────────────────────
@@ -55,6 +63,29 @@ export interface TdsReportFilter {
nearThresholdOnly: boolean;
}
+/**
+ * One of the customer's accounts as the report sees it: whether its interest is being counted
+ * toward the deduction, and if not, why not. A customer can hold savings, fixed and recurring
+ * accounts together, and the branch has to see which of them the deduction is actually watching.
+ */
+export interface TdsAccountLine {
+ accountNo: string;
+ type: 'FD' | 'RD' | 'SB' | 'OTHER';
+ /** True when the account's interest counts toward this customer's threshold. */
+ tracked: boolean;
+ /** Plain-language reason, shown beside the account. */
+ reason: string;
+ /** Interest credited in the year. */
+ interest: number;
+ /** How many interest postings made up that figure. */
+ postings: number;
+ lastPostedOn: string | null;
+ /** TDS the platform actually withheld from this account in the year. */
+ tdsDeducted: number;
+ /** Set when the tax is deducted against someone other than the account's own holder. */
+ holderName: string | null;
+}
+
/** How close to the trigger counts as "worth chasing a declaration for". */
export const NEAR_THRESHOLD_BAND = 6_000;
@@ -100,6 +131,15 @@ export interface TdsInterestRow {
paidWithoutDeduction: number;
panOnRecord: boolean;
rateApplied: number;
+ /** Every account the customer holds, tracked or not. */
+ accounts: TdsAccountLine[];
+ /** How many of them count toward the deduction. */
+ trackedAccounts: number;
+ /**
+ * TDS the platform actually withheld this year. `schedule.totalTds` is what this report works
+ * out is due; the two should agree, and a difference is the first thing to investigate.
+ */
+ actualTds: number;
}
function emptyQuarters(): Record {
@@ -118,7 +158,8 @@ let rowCache: TdsInterestRow[] = [];
export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
// Keyed on the data set as well as the year: the rows are memoised, and a fresh load would
// otherwise keep serving the previous customer's figures under the same financial year.
- const key = fy + '|' + tdsDeducteesVersion;
+ // The tax configuration too: an administrator's save must not be hidden behind rows built on the old rates.
+ const key = fy + '|' + tdsDeducteesVersion + '|' + fyTaxConfigVersion;
if (rowCacheKey === key) {
return rowCache;
}
@@ -152,14 +193,39 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
let exemptInterest = 0;
let jointHeldInterest = 0;
+ const accountLines: TdsAccountLine[] = [];
for (const account of deductee.accounts) {
- const inYear = account.credits.filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0);
+ const yearCredits = account.credits.filter((c) => fyOf(c.date) === fy);
+ const inYear = yearCredits.reduce((s, c) => s + c.amount, 0);
+ const type = accountTypeOf(account.depositTypeCode);
+ let tracked = false;
+ let reason: string;
if (!isTdsApplicable(account)) {
exemptInterest += inYear;
+ reason = type === 'SB' ? 'Savings account — no TDS on savings interest' : 'Not a time deposit — no TDS';
} else if (tdsDeducteeOf(account) !== deductee.clientId) {
jointHeldInterest += inYear;
+ reason = 'Joint deposit — counted under the first-named holder';
+ } else {
+ tracked = true;
+ reason = 'Its interest received in savings counts toward the TDS limit';
}
+ const report = account as TdsReportAccount;
+ accountLines.push({
+ accountNo: account.accountNo || '',
+ type,
+ tracked,
+ reason,
+ interest: inYear,
+ postings: yearCredits.length,
+ lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null),
+ tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0),
+ holderName:
+ report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : null
+ });
}
+ // Tracked accounts first, so the deposits that drive the figure lead the list.
+ accountLines.sort((a, b) => Number(b.tracked) - Number(a.tracked));
const crossedLine = schedule.lines.find((l) => l.thresholdCrossedHere);
const headroom = Math.max(0, schedule.threshold - schedule.totalInterest);
@@ -188,7 +254,10 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] {
tdsSaved: declaration ? Math.max(0, withoutDeclaration.totalTds - schedule.totalTds) : 0,
paidWithoutDeduction: schedule.shieldedInterest,
panOnRecord: isWellFormedPan(deductee.pan),
- rateApplied: schedule.rateApplied
+ rateApplied: schedule.rateApplied,
+ accounts: accountLines,
+ trackedAccounts: accountLines.filter((a) => a.tracked).length,
+ actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0)
});
}
@@ -208,10 +277,29 @@ function overallModeFor(deductee: TdsDeductee): TdsMode {
return applicable.some((a) => tdsModeFor(a) === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
+function accountTypeOf(code: string | undefined): TdsAccountLine['type'] {
+ const c = (code || '').toLowerCase();
+ if (c.includes('fixeddeposit')) {
+ return 'FD';
+ }
+ if (c.includes('recurringdeposit')) {
+ return 'RD';
+ }
+ if (c.includes('savings')) {
+ return 'SB';
+ }
+ return 'OTHER';
+}
+
+/** Branches are compared by office name, case and spacing aside — the platform and the session both carry the name. */
+function sameBranch(a: string, b: string): boolean {
+ return (a || '').trim().toLowerCase() === (b || '').trim().toLowerCase();
+}
+
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) {
+ if (filter.branchCode && !sameBranch(row.branchCode, filter.branchCode)) {
return false;
}
if (filter.band && row.band !== filter.band) {
@@ -246,12 +334,16 @@ export interface TdsInterestSummary {
/** Savings interest, outside the section. */
exemptInterest: number;
totalTds: number;
+ /** TDS the platform actually withheld — set against `totalTds`, which is what is due. */
+ actualTds: number;
crossed: number;
nearThreshold: number;
declarations: number;
tdsSaved: number;
paidWithoutDeduction: number;
noPan: number;
+ /** Customers holding at least one account whose interest counts toward the deduction. */
+ trackedCustomers: number;
byQuarter: { quarter: Quarter; interest: number; tds: number }[];
}
@@ -273,26 +365,50 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary
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),
+ actualTds: rows.reduce((s, r) => s + r.actualTds, 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,
+ // Only customers with a deposit the deduction watches: a savings-only customer without a PAN
+ // is never deducted at the higher rate, so counting them overstated the problem.
+ noPan: rows.filter((r) => !r.panOnRecord && r.trackedAccounts > 0).length,
+ trackedCustomers: rows.filter((r) => r.trackedAccounts > 0).length,
byQuarter
};
}
// ─────────────────────── Report 2: remittance ───────────────────────
-export interface TdsRemittanceRow extends TdsChallan {
- /** Statutory due date has passed with nothing deposited. */
+/**
+ * One deduction month: what the platform withheld, and what Head Office remitted against it.
+ *
+ * Driven by the withhold-tax postings and the challans entered, never by this report's own
+ * computation — a return reconciles what was deducted, not what should have been.
+ */
+export interface TdsRemittanceRow {
+ fy: string;
+ quarter: Quarter;
+ /** Month the tax was deducted in, e.g. '2026-06'. */
+ month: string;
+ deducted: number;
+ postings: number;
+ /** Live challans entered against this month. */
+ challans: TdsChallan[];
+ deposited: number;
+ lastDepositedOn: string | null;
+ dueDate: string;
+ /** Deducted but not yet remitted. */
+ outstanding: number;
+ /** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */
+ excess: number;
+ /** Something is still outstanding and the due date has passed. */
overdue: boolean;
- /** Deposited, but after the due date. */
+ /** A challan reached the Government after the due date. */
late: boolean;
- /** Months or part months of delay. */
delayMonths: number;
- /** Interest charged for depositing late. */
+ /** Interest the bank owes for depositing late. */
defaultInterest: number;
/** Return the quarter's deduction is reported in. */
returnDueDate: string;
@@ -305,41 +421,83 @@ const RETURN_DUE: Record = {
4: '31 May'
};
-/** Tax deducted per quarter, from the engine, for one year. */
-export function deductedByQuarter(fy: string): Record {
- const out: Record = { 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;
- }
+export function quarterOfMonth(month: string): Quarter {
+ const m = Number(month.slice(5, 7));
+ if (m >= 4 && m <= 6) {
+ return 1;
}
- return out;
+ if (m >= 7 && m <= 9) {
+ return 2;
+ }
+ if (m >= 10) {
+ return 3;
+ }
+ return 4;
}
-export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] {
- const deducted: Record> = {};
- for (const year of Object.keys(FY_TAX_CONFIG)) {
- deducted[year] = deductedByQuarter(year);
- }
- const challans = tdsChallans(deducted)[fy] ?? [];
+function round2(n: number): number {
+ return Math.round(n * 100) / 100;
+}
- 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;
+export function tdsRemittanceRows(
+ fy: string,
+ deducted: TdsDeductedMonth[],
+ challans: TdsChallan[],
+ asAt: string
+): TdsRemittanceRow[] {
+ const live = challans.filter((c) => !c.reversed && c.fy === fy);
+ const months = [
+ ...new Set([
+ ...deducted.map((d) => d.month),
+ ...live.map((c) => c.month)
+ ])
+ ].sort();
+
+ return months.map((month) => {
+ const d = deducted.find((x) => x.month === month);
+ const amount = d?.amount ?? 0;
+ const forMonth = live.filter((c) => c.month === month);
+ const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0));
+ const lastDepositedOn = forMonth.reduce(
+ (last, c) => (!last || c.depositedOn > last ? c.depositedOn : last),
+ null
+ );
+ const dueDate = depositDueDate(month);
+ const outstanding = Math.max(0, round2(amount - deposited));
+ const excess = Math.max(0, round2(deposited - amount));
+ const overdue = outstanding > 0 && dueDate < asAt;
+ const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate);
+ const late = lateChallans.length > 0;
+
+ // Interest runs per challan from the due date to the day it was paid, and on whatever is still
+ // outstanding from the due date to today.
+ let defaultInterest = lateChallans.reduce(
+ (s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest,
+ 0
+ );
+ if (overdue) {
+ defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest;
+ }
+ const settledOn = overdue ? asAt : lastDepositedOn;
+ const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0;
return {
- ...c,
+ fy,
+ quarter: quarterOfMonth(month),
+ month,
+ deducted: amount,
+ postings: d?.postings ?? 0,
+ challans: forMonth,
+ deposited,
+ lastDepositedOn,
+ dueDate,
+ outstanding,
+ excess,
overdue,
late,
delayMonths,
defaultInterest,
- returnDueDate: RETURN_DUE[c.quarter]
+ returnDueDate: RETURN_DUE[quarterOfMonth(month)]
};
});
}
@@ -347,9 +505,9 @@ export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[]
/**
* 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.
+ * `difference` is the control figure: tax actually deducted, less what reached the Government,
+ * less what is still within its due date. Anything other than zero means a month is overdue or
+ * a challan sits against the wrong month.
*/
export interface Tds26qSummary {
fy: string;
@@ -360,9 +518,13 @@ export interface Tds26qSummary {
interestWithoutDeduction: number;
tdsAtStandardRate: number;
tdsAtHigherRate: number;
+ /** What this report works out was due. */
+ calculatedTds: number;
+ /** What the platform actually withheld from accounts. */
totalDeducted: number;
totalDeposited: number;
pendingDeposit: number;
+ overdueAmount: number;
/** Deducted less deposited less still-pending. Must be zero. */
difference: number;
defaultInterest: number;
@@ -370,29 +532,31 @@ export interface Tds26qSummary {
overdueChallans: number;
}
-export function tds26qSummary(fy: string, asAt: string): Tds26qSummary {
+export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], 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);
+ const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0));
+ const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0));
+ const pendingDeposit = round2(remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0));
+ const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0));
return {
fy,
- deductees: rows.length,
+ deductees: rows.filter((r) => r.trackedAccounts > 0).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),
+ tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0),
+ tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0),
+ calculatedTds: rows.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
+ overdueAmount,
+ difference: round2(totalDeducted - totalDeposited - pendingDeposit),
+ defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0),
+ lateChallans: remittance.filter((r) => r.late).length,
+ overdueChallans: remittance.filter((r) => r.overdue).length
};
}
diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts
index b8d1f4b07..27b7730d4 100644
--- a/src/app/tds-reports/tds-reports.service.ts
+++ b/src/app/tds-reports/tds-reports.service.ts
@@ -13,15 +13,52 @@ import { Observable, map } from 'rxjs';
/** Custom Helpers */
import { toIsoDate } from '../core/utils/api-date';
-import { TdsDeductee } from './tds-reports-data';
+import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute';
+import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data';
+
+/** A declaration as recorded on the platform. */
+export interface TdsDeclarationRecord {
+ id: number;
+ clientId: number;
+ clientName?: string;
+ fy: string;
+ formType: '15G' | '15H' | '121';
+ uin: string | null;
+ acceptedOn: string;
+ estimatedIncome: number | null;
+ revoked: boolean;
+ revokedOn: string | null;
+ revokeReason: string | null;
+}
+
+export interface NewTdsDeclaration {
+ clientId: number;
+ fy: string;
+ formType: string;
+ acceptedOn: string;
+ uin?: string;
+ estimatedIncome?: number | null;
+}
+
+export interface NewTdsChallan {
+ fy: string;
+ month: string;
+ amount: number;
+ challanNo: string;
+ bsrCode: string;
+ depositedOn: string;
+ paidFromGlAccountId: number;
+ remarks?: string;
+}
/**
- * Deposit interest for the TDS reports.
+ * Deposit interest, declarations and challans for the TDS screens.
*
- * The platform returns the interest it actually credited — nothing else. A deposit is the
- * customer's own money arriving and a fee is a charge, so neither is income and neither is sent.
- * Working the figure out here from a balance and a rate would produce a number the passbook does
- * not show.
+ * On an FD or RD the platform returns the interest that reached the customer's savings account and
+ * the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit
+ * is the customer's own money arriving and a fee is a charge, so neither is income and neither is
+ * sent. Working the figure out here from a balance and a rate would produce a number the passbook
+ * does not show.
*/
@Injectable({ providedIn: 'root' })
export class TdsReportsService {
@@ -31,60 +68,131 @@ export class TdsReportsService {
* @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027.
* Omit to let the platform use the year the business date falls in.
*/
- getDepositInterest(financialYear?: number): Observable {
- const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined;
+ getDepositInterest(financialYear?: number, clientId?: number): Observable {
+ let params = new HttpParams();
+ if (financialYear) {
+ params = params.set('financialYear', String(financialYear));
+ }
+ if (clientId) {
+ params = params.set('clientId', String(clientId));
+ }
return this.http
.get('/tds/deposit-interest', { params })
.pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r))));
}
+ getDeclarations(clientId: number): Observable {
+ return this.http.get('/tds/declarations', {
+ params: new HttpParams().set('clientId', String(clientId))
+ });
+ }
+
+ createDeclaration(declaration: NewTdsDeclaration): Observable {
+ return this.http.post('/tds/declarations', declaration);
+ }
+
+ revokeDeclaration(id: number, reason: string): Observable {
+ return this.http.post(`/tds/declarations/${id}/revoke`, { reason });
+ }
+
+ /** Tax actually withheld per month, and the business date the books are on. */
+ getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> {
+ return this.http.get('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe(
+ map((res) => ({
+ asAt: res?.asAt ?? '',
+ months: (res?.months || []).map((m: any) => ({
+ month: m.month,
+ amount: Number(m.amount) || 0,
+ postings: Number(m.postings) || 0
+ }))
+ }))
+ );
+ }
+
+ getChallans(fy: string): Observable {
+ return this.http
+ .get('/tds/challans', { params: new HttpParams().set('fy', fy) })
+ .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
+ }
+
+ getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> {
+ return this.http.get('/tds/challans/template');
+ }
+
+ createChallan(challan: NewTdsChallan): Observable {
+ return this.http
+ .post('/tds/challans', challan)
+ .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
+ }
+
+ reverseChallan(id: number): Observable {
+ return this.http
+ .post(`/tds/challans/${id}/reverse`, {})
+ .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r))));
+ }
+
+ private toChallan(r: any): TdsChallan {
+ return {
+ id: r.id,
+ fy: r.fy,
+ month: r.month,
+ amount: Number(r.amount) || 0,
+ challanNo: r.challanNo,
+ bsrCode: r.bsrCode,
+ depositedOn: (r.depositedOn || '').slice(0, 10),
+ paidFrom: r.paidFrom || '',
+ remarks: r.remarks ?? null,
+ reversed: !!r.reversed
+ };
+ }
+
/**
- * Shapes one platform row into what the section 194A computation expects.
+ * Shapes one platform row into what the TDS computation expects.
*
* Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation
- * compares them as strings when deciding which financial year a credit falls in.
+ * compares them as strings when deciding which financial year a credit falls in. The platform
+ * has already grouped a minor's deposit under the guardian and a joint deposit under its primary
+ * holder, so every account here belongs to the deductee it is listed under.
*/
private toDeductee(row: any): TdsDeductee {
+ const dob = toIsoDate(row.dateOfBirth);
+ const credit = (a: any) => (c: any) => ({
+ date: toIsoDate(c.date),
+ amount: Number(c.amount) || 0,
+ accountNo: c.accountNo || a.accountNo || ''
+ });
return {
clientId: row.clientId,
customerId: row.customerId || '',
name: row.name || '',
pan: row.pan || '',
- dob: toIsoDate(row.dateOfBirth),
+ dob,
branchCode: row.branchName || '',
- // A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform
- // carries the kind but not the acceptance date, so the year's start stands in — a
- // declaration is operative for the year it was given, which is what the computation needs.
- declarations: this.declarationsOf(row),
+ declarations: this.declarationsOf(row.declarations || [], dob),
accounts: (row.accounts || []).map((a: any) => ({
id: a.id,
accountNo: a.accountNo || '',
depositTypeCode: a.depositTypeCode || '',
- accountStatus: a.accountStatus || undefined,
+ // The deductee, not the holder: the grouping above already resolved whose PAN it is.
clientId: a.clientId ?? row.clientId,
- credits: (a.credits || []).map((c: any) => ({
- date: toIsoDate(c.date),
- amount: Number(c.amount) || 0,
- accountNo: c.accountNo || a.accountNo || ''
- }))
+ holderClientId: a.holderClientId ?? undefined,
+ holderName: a.holderName ?? undefined,
+ credits: (a.credits || []).map(credit(a)),
+ deductions: (a.deductions || []).map(credit(a))
}))
};
}
- private declarationsOf(row: any): Record {
- const kind = (row.form15Type || '').trim().toUpperCase();
- if (kind !== '15G' && kind !== '15H') {
- return {};
- }
- const out: Record = {};
- // Applied to the years the reports can show. Without an acceptance date on the record the
- // declaration is treated as operative from the start of each year rather than mid-year.
- for (const year of [
- 2025,
- 2026
- ]) {
- const fy = `FY ${year}-${String(year + 1).slice(2)}`;
- out[fy] = { kind, acceptedOn: `${year}-04-01`, fy };
+ /**
+ * Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121
+ * carries no G/H split, so the band follows the customer's age in that year.
+ */
+ private declarationsOf(rows: any[], dob: string): Record {
+ const out: Record = {};
+ for (const d of rows) {
+ const fy = d.fy;
+ const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy);
+ out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined };
}
return out;
}
diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss
index 8deb7825c..1fa00d346 100644
--- a/src/app/tds-reports/tds-reports.shared.scss
+++ b/src/app/tds-reports/tds-reports.shared.scss
@@ -647,3 +647,122 @@
color: var(--tdsr-ink);
white-space: nowrap;
}
+
+/* ── Help panel (interest report) ── */
+.tdsr__help {
+ margin-bottom: 16px;
+ padding: 14px 18px;
+ border: 1px solid color-mix(in srgb, var(--tdsr-blue) 25%, transparent);
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--tdsr-blue) 5%, transparent);
+ line-height: 1.6;
+}
+
+.tdsr__help-title {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ margin-bottom: 6px;
+ font-weight: 700;
+ color: var(--tdsr-blue);
+}
+
+.tdsr__help-list {
+ margin: 0;
+ padding-left: 20px;
+
+ li + li {
+ margin-top: 2px;
+ }
+}
+
+/* ── Action chips under the summary ── */
+.tdsr__chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin: -6px 0 16px;
+}
+
+.tdsr__chip {
+ padding: 5px 12px;
+ border: 1px solid var(--tdsr-line);
+ border-radius: 999px;
+ background: var(--tdsr-bg);
+ font: inherit;
+ font-size: 12px;
+ font-weight: 600;
+
+ &--near {
+ border-color: color-mix(in srgb, var(--tdsr-blue) 30%, transparent);
+ color: var(--tdsr-blue);
+ cursor: pointer;
+ }
+
+ &--ok {
+ border-color: color-mix(in srgb, var(--tdsr-green) 35%, transparent);
+ color: var(--tdsr-green);
+ }
+
+ &--warn {
+ border-color: color-mix(in srgb, var(--tdsr-amber) 35%, transparent);
+ color: var(--tdsr-amber);
+ }
+}
+
+.tdsr__row--click {
+ cursor: pointer;
+
+ &:hover > td {
+ background: var(--tdsr-bg);
+ }
+}
+
+.tdsr__detail-h {
+ margin: 4px 0 8px;
+ font-weight: 700;
+
+ .tdsr__table--inner + & {
+ margin-top: 16px;
+ }
+}
+
+/* ── Challan form (remittance report) ── */
+.tdsr__form {
+ padding-bottom: 14px;
+}
+
+.tdsr__filters--flat {
+ margin: 0;
+ border: 0;
+ box-shadow: none;
+}
+
+.tdsr__form-actions {
+ display: flex;
+ gap: 8px;
+ justify-content: flex-end;
+ padding: 0 16px;
+}
+
+.tdsr__btn--primary {
+ border-color: var(--tdsr-blue);
+ background: var(--tdsr-blue);
+ color: #fff;
+}
+
+.tdsr__btn--sm {
+ padding: 3px 10px;
+ font-size: 11.5px;
+}
+
+.tdsr__link {
+ margin-left: 6px;
+ padding: 0;
+ border: 0;
+ background: none;
+ color: var(--tdsr-amber);
+ font: inherit;
+ text-decoration: underline;
+ cursor: pointer;
+}
diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts
index bbb50e748..87f74f0dc 100644
--- a/src/app/tds-reports/tds-statutory.compute.ts
+++ b/src/app/tds-reports/tds-statutory.compute.ts
@@ -32,7 +32,6 @@ import {
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. */
@@ -131,7 +130,7 @@ function monthLabel(key: string): string {
* 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 {
+export function declarationStatement(fy: string, asAt: string): DeclarationStatement {
const year = normaliseFy(fy);
const cfg = FY_TAX_CONFIG[year];
if (!cfg) {
diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts
index c27a3984f..5645ad422 100644
--- a/src/app/web-app.component.ts
+++ b/src/app/web-app.component.ts
@@ -8,7 +8,7 @@
/* eslint-disable @angular-eslint/prefer-inject */
/** Angular Imports */
-import { Component, OnInit, HostListener, HostBinding, OnDestroy } from '@angular/core';
+import { Component, OnInit, HostListener, HostBinding, OnDestroy, inject } from '@angular/core';
import { Router, NavigationEnd, ActivatedRoute } from '@angular/router';
import { Title } from '@angular/platform-browser';
import { MatSnackBar } from '@angular/material/snack-bar';
@@ -30,6 +30,7 @@ import { ThemeStorageService } from './shared/theme-picker/theme-storage.service
import { AlertService } from './core/alert/alert.service';
import { AuthenticationService } from './core/authentication/authentication.service';
import { SettingsService } from './settings/settings.service';
+import { SystemService } from './system/system.service';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service';
import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component';
@@ -102,6 +103,7 @@ export class WebAppComponent implements OnInit, OnDestroy {
private authSubscription: Subscription;
private destroy$ = new Subject();
+ private systemService = inject(SystemService);
/**
* @param {Router} router Router for navigation.
@@ -254,6 +256,27 @@ export class WebAppComponent implements OnInit, OnDestroy {
}
// Set default max date picker as Today
this.settingsService.setBusinessDate(this.dateUtils.formatDate(new Date(), SettingsService.businessDateFormat));
+ // ...then replace it with the bank's own "today" (business date when enabled, server date when
+ // not) once signed in, and again whenever the business date or its on/off switch changes.
+ // isAuthenticated$ fires at the start of a fresh login, before the auth header is set - a call
+ // then gets a 401 Basic challenge and the browser's own sign-in box. So it only covers a restored
+ // session (credentials already saved); a fresh login is caught by 'Authentication Success'.
+ this.authenticationService.isAuthenticated$.pipe(takeUntil(this.destroy$)).subscribe((loggedIn) => {
+ if (loggedIn && this.authenticationService.isAuthenticated()) {
+ this.refreshBusinessToday();
+ }
+ });
+ this.alertService.alertEvent
+ .pipe(
+ filter(
+ (alertEvent: Alert) =>
+ alertEvent.type === 'Authentication Success' ||
+ alertEvent.type === SettingsService.businessDateType + ' Set' ||
+ alertEvent.type === SettingsService.businessDateType + ' Set Config'
+ ),
+ takeUntil(this.destroy$)
+ )
+ .subscribe(() => this.refreshBusinessToday());
// Set the server list from the env var FINERACT_API_URLS, but avoid overwriting "Add new server" user choice
if (!this.settingsService.servers) {
this.settingsService.setServers(environment.baseApiUrls.split(','));
@@ -298,6 +321,19 @@ export class WebAppComponent implements OnInit, OnDestroy {
}
}
+ /** Stores the server's "today" where every date default reads it (see core/utils/business-today). */
+ private refreshBusinessToday(): void {
+ this.systemService.getBusinessToday().subscribe({
+ next: (res) => {
+ if (res?.today) {
+ this.settingsService.setBusinessDate(res.today);
+ }
+ },
+ // Unreachable server or older backend: keep whatever date is already stored.
+ error: () => {}
+ });
+ }
+
ngOnDestroy() {
this.destroy$.next();
this.destroy$.complete();
diff --git a/src/assets/translations/en-US.json b/src/assets/translations/en-US.json
index 9160611c2..f5fd8d159 100644
--- a/src/assets/translations/en-US.json
+++ b/src/assets/translations/en-US.json
@@ -2236,6 +2236,9 @@
"Interest free period": "Interest free period",
"Interest method": "Interest method",
"Interest on savings": "Interest on savings",
+ "Fixed deposit control": "Fixed deposit control",
+ "Fixed deposit reference": "Fixed deposit reference",
+ "Interest on fixed deposits": "Interest on fixed deposits",
"Interest rate": "Interest Rate",
"Interest recalculation compounding on": "Interest recalculation compounding on",
"Interest waived on": "Interest waived on",
@@ -2793,6 +2796,7 @@
"Savings Product": "Savings Product",
"Savings SubType": "Savings SubType",
"Savings transfers in suspense": "Savings transfers in suspense",
+ "Transfers in suspense": "Transfers in suspense",
"Schedule Date": "Schedule Date",
"Share Details": "Share Details",
"Scheduler Jobs": "Scheduler Jobs",
@@ -2967,6 +2971,7 @@
"Transfer Interest to Savings Account": "Transfer Interest to Savings Account",
"Transfer Type": "Transfer Type",
"Transfer in suspense": "Transfer in suspense",
+ "Transfer to GL Account": "Transfer to GL Account",
"Transfer to Savings": "Transfer to Savings",
"Transfer to savings account": "Transfer to savings account",
"Trigger Type": "Trigger Type",
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts
index d7885aac8..7e1be2f0b 100644
--- a/src/environments/environment.prod.ts
+++ b/src/environments/environment.prod.ts
@@ -19,8 +19,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
- fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'lincpay',
- fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'lincpay',
+ fineractPlatformTenantId: loadedEnv['fineractPlatformTenantId'] || 'utkarshbank',
+ fineractPlatformTenantIds: loadedEnv['fineractPlatformTenantIds'] || 'utkarshbank,lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv['fineractApiUrls'] || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index 700ff10f9..dabb366fb 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -22,8 +22,8 @@ export const environment = {
version: env.mifos_x.version,
hash: env.mifos_x.hash,
// For connecting to server running elsewhere update the tenant identifier
- fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'lincpay',
- fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'lincpay',
+ fineractPlatformTenantId: loadedEnv.fineractPlatformTenantId || 'utkarshbank',
+ fineractPlatformTenantIds: loadedEnv.fineractPlatformTenantIds || 'utkarshbank,lincpay',
// For connecting to others servers running elsewhere update the base API URL
baseApiUrls:
loadedEnv.fineractApiUrls || 'https://demo.mifos.community,https://localhost:8443,' + window.location.origin,
diff --git a/src/main.scss b/src/main.scss
index 16f91750d..c74b84479 100644
--- a/src/main.scss
+++ b/src/main.scss
@@ -55,6 +55,19 @@
box-shadow: none;
}
+// ===== Payment / Renewal Dialog Panel =====
+.pmt-dialog-panel .mat-mdc-dialog-container .mdc-dialog__surface {
+ padding: 0 !important;
+ border-radius: 16px !important;
+ overflow: hidden !important;
+ box-shadow: 0 20px 40px rgb(15 23 42 / 25%) !important;
+}
+
+.pmt-dialog-panel .mat-mdc-dialog-content {
+ padding: 0 !important;
+ max-height: none !important;
+}
+
.ipd-dialog .mdc-dialog__surface {
border-radius: 20px !important;
background: transparent !important;