diff --git a/src/app/cashier/account-register/account-register.component.ts b/src/app/cashier/account-register/account-register.component.ts
index 683841a11..06ce5e73d 100644
--- a/src/app/cashier/account-register/account-register.component.ts
+++ b/src/app/cashier/account-register/account-register.component.ts
@@ -12,6 +12,7 @@ import { FormsModule } from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
import { catchError, of } from 'rxjs';
import { CashierService } from '../cashier.service';
+import { businessToday } from 'app/core/utils/business-today';
interface RegisterRow {
accountId: number;
@@ -83,7 +84,7 @@ export class AccountRegisterComponent implements OnInit {
/** Local date — 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/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/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 1cb271588..869c1435f 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
@@ -3562,7 +3563,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
@@ -4223,7 +4224,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')}`;
})();
@@ -4599,7 +4600,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')}`;
}
@@ -5121,7 +5122,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);
@@ -6009,7 +6010,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 = {
@@ -6204,7 +6205,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);
@@ -6309,7 +6310,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
@@ -6420,7 +6421,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/clients-view/form121-tab/form121-tab.component.html b/src/app/clients/clients-view/form121-tab/form121-tab.component.html
index 9c71e8840..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
@@ -116,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.
+ }
}
@if (eligibility) {
@for (reason of eligibility.reasons; track reason) {
diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss
index 4f3666cf1..4b2aa7020 100644
--- a/src/app/clients/clients-view/form121-tab/form121-tab.component.scss
+++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.scss
@@ -332,3 +332,61 @@
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 600;
}
+
+/* ── Recording a declaration ── */
+.f121__btn {
+ padding: 6px 14px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ background: #fff;
+ font: inherit;
+ font-size: 12.5px;
+ font-weight: 600;
+ cursor: pointer;
+
+ &:disabled {
+ opacity: 0.55;
+ cursor: not-allowed;
+ }
+
+ &--primary {
+ border-color: #1d4ed8;
+ background: #1d4ed8;
+ color: #fff;
+ }
+
+ &--warn {
+ border-color: #b45309;
+ color: #b45309;
+ }
+}
+
+.f121__decl-form {
+ display: grid;
+ gap: 10px;
+ padding: 12px 16px;
+
+ label {
+ display: grid;
+ gap: 4px;
+ font-size: 12px;
+
+ span {
+ color: #6b7280;
+ font-weight: 600;
+ }
+
+ input {
+ max-width: 280px;
+ padding: 6px 8px;
+ border: 1px solid #d1d5db;
+ border-radius: 6px;
+ font: inherit;
+ }
+ }
+}
+
+.f121__decl-actions {
+ display: flex;
+ gap: 8px;
+}
diff --git a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts
index ca9d1252d..161f74dbf 100644
--- a/src/app/clients/clients-view/form121-tab/form121-tab.component.ts
+++ b/src/app/clients/clients-view/form121-tab/form121-tab.component.ts
@@ -23,6 +23,8 @@ import { SavingsService } from 'app/savings/savings.service';
import { FixedDepositsService } from 'app/deposits/fixed-deposits/fixed-deposits.service';
import { RecurringDepositsService } from 'app/deposits/recurring-deposits/recurring-deposits.service';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
+import { TdsDeclarationRecord, TdsReportsService } from 'app/tds-reports/tds-reports.service';
+import { TdsDeductee } from 'app/tds-reports/tds-reports-data';
/** Domain logic */
import {
@@ -111,6 +113,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private savingsService = inject(SavingsService);
private fixedDepositsService = inject(FixedDepositsService);
private recurringDepositsService = inject(RecurringDepositsService);
+ private tdsService = inject(TdsReportsService);
private destroy$ = new Subject();
@@ -124,23 +127,25 @@ export class Form121TabComponent implements OnInit, OnDestroy {
pan = '';
panWellFormed = false;
- // ── Declaration on record ──
- declarationOnRecord = false;
+ // ── Declarations on record ──
+ /**
+ * Every declaration the customer has given, each for one year and from the day the branch
+ * accepted it. The KYC record only ever held a type and a year, which made a September
+ * declaration shield interest back to April and let last year's form run on.
+ */
+ declarations: TdsDeclarationRecord[] = [];
/**
* The form actually in force for the selected year. From FY 2026-27 a single
* Form 121 replaced the 15G/15H split, so this is derived from the year rather
* than from the payee's age.
*/
declarationForm: DeclarationForm = '121';
- /** Kind recorded at KYC, meaningful only for a pre-2026 year. */
- declarationKind: '15G' | '15H' = '15G';
- declarationFy = '';
- /**
- * The KYC step records only the year, not the date the branch accepted the
- * declaration, so it has to be treated as covering the whole year. Surfaced on
- * screen rather than hidden — a mid-year acceptance would change the figures.
- */
- acceptanceDateKnown = false;
+
+ // ── Recording a declaration ──
+ showDeclarationForm = false;
+ savingDeclaration = false;
+ declarationError = '';
+ newDeclaration = { acceptedOn: '', uin: '', estimatedIncome: null as number | null };
eligibility: EligibilityResult | null = null;
@@ -157,9 +162,9 @@ export class Form121TabComponent implements OnInit, OnDestroy {
configMissing = false;
/** Raw account inputs, kept so the year selector can recompute cheaply. */
- private accountInputs: (TdsAccountInput & { meta: Form121AccountRow })[] = [];
- /** Withheld-tax postings by FY, from the ledger. */
- private actualTdsByFy = new Map();
+ private accountInputs: (TdsAccountInput & { meta: Form121AccountRow; taxLines: InterestCredit[] })[] = [];
+ /** This customer's FD/RD interest received in savings, and TDS taken there, per year. */
+ private platformByFy = new Map();
ngOnInit(): void {
this.route.parent.params.pipe(takeUntil(this.destroy$)).subscribe((params: any) => {
@@ -182,13 +187,15 @@ export class Form121TabComponent implements OnInit, OnDestroy {
forkJoin({
client: this.clientsService.getClientData(this.clientId, true).pipe(catchError(() => of(null))),
kyc: this.clientsService.getClientKyc(this.clientId, true).pipe(catchError(() => of(null))),
- accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null)))
+ accounts: this.clientsService.getClientAccountData(this.clientId).pipe(catchError(() => of(null))),
+ declarations: this.tdsService.getDeclarations(Number(this.clientId)).pipe(catchError(() => of([])))
})
.pipe(
takeUntil(this.destroy$),
- switchMap(({ client, kyc, accounts }) => {
+ switchMap(({ client, kyc, accounts, declarations }) => {
this.applyClient(client);
this.applyKyc(kyc);
+ this.declarations = declarations || [];
const deposits = this.depositAccountsOf(accounts);
return deposits.length ? forkJoin(deposits.map((a) => this.fetchAccount(a))) : of([] as any[]);
}),
@@ -212,12 +219,8 @@ export class Form121TabComponent implements OnInit, OnDestroy {
private applyKyc(kyc: any): void {
this.pan = (kyc?.panNumber ?? '').toUpperCase();
this.panWellFormed = isWellFormedPan(this.pan);
- this.declarationOnRecord = !!kyc?.form15Applicable;
- this.declarationKind = kyc?.form15Type === '15H' ? '15H' : '15G';
this.declarationForm =
this.fy && FY_TAX_CONFIG[normaliseFy(this.fy)] ? declarationFormFor(this.fy, this.dateOfBirth) : '121';
- this.declarationFy = kyc?.form15FY ?? '';
- this.acceptanceDateKnown = false;
}
/** Savings, FD and RD accounts — everything that can credit interest. */
@@ -251,7 +254,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
*/
private ingest(details: any[]): void {
this.accountInputs = [];
- this.actualTdsByFy = new Map();
+ this.platformByFy = new Map();
const fys = new Set();
const clientIdNum = Number(this.clientId);
@@ -272,6 +275,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
};
const credits: InterestCredit[] = [];
+ const taxLines: InterestCredit[] = [];
let tdsDeducted = 0;
for (const t of d.transactions ?? []) {
@@ -289,8 +293,7 @@ export class Form121TabComponent implements OnInit, OnDestroy {
} else if (t.transactionType?.withholdTax) {
const amount = t.amount ?? 0;
tdsDeducted += amount;
- const txnFy = fyOf(iso);
- this.actualTdsByFy.set(txnFy, (this.actualTdsByFy.get(txnFy) ?? 0) + amount);
+ taxLines.push({ date: iso, amount, accountNo: ref.accountNo });
}
}
@@ -313,12 +316,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
deducteeClientId: deductee
};
- this.accountInputs.push({ ...ref, credits, meta });
+ this.accountInputs.push({ ...ref, credits, taxLines, meta });
}
this.availableFys = [...fys].sort().reverse();
if (!this.availableFys.includes(this.fy)) {
- this.fy = this.availableFys[0] ?? (this.declarationFy ? normaliseFy(this.declarationFy) : '');
+ this.fy = this.availableFys[0] ?? this.declarations[0]?.fy ?? Object.keys(FY_TAX_CONFIG).sort().reverse()[0] ?? '';
}
}
@@ -338,11 +341,35 @@ export class Form121TabComponent implements OnInit, OnDestroy {
const year = normaliseFy(this.fy);
this.configMissing = !FY_TAX_CONFIG[year];
+ // TDS is tracked on FD/RD interest once it reaches savings, and deducted there. Those figures live
+ // on the platform, not in the deposit's own transactions, so they are fetched once per year and
+ // the computation waits for them.
+ if (!this.platformByFy.has(year)) {
+ this.loadPlatformYear(year);
+ return;
+ }
+ const platform = this.platformByFy.get(year);
+ for (const a of this.accountInputs) {
+ if (!a.meta.tdsApplicable) {
+ continue;
+ }
+ const match = platform?.accounts.find((p) => p.accountNo === a.accountNo);
+ a.credits = match?.credits ?? [];
+ a.taxLines = match?.deductions ?? [];
+ }
+
// Per-account interest for the selected year, for the three lists.
for (const a of this.accountInputs) {
a.meta.interest = a.credits.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0);
+ // The selected year only - this used to total every year the account had ever been taxed in.
+ a.meta.tdsDeducted = a.meta.tdsApplicable
+ ? a.taxLines.filter((c) => fyOf(c.date) === year).reduce((s, c) => s + c.amount, 0)
+ : 0;
}
- this.actualTds = this.actualTdsByFy.get(year) ?? 0;
+ // What was actually debited for this customer as deductee, wherever the savings account sits.
+ this.actualTds = this.accountInputs
+ .filter((a) => a.meta.tdsApplicable)
+ .reduce((s, a) => s + a.meta.tdsDeducted, 0);
if (!this.dateOfBirth || this.configMissing) {
return;
@@ -382,21 +409,95 @@ export class Form121TabComponent implements OnInit, OnDestroy {
return applicable.some((a) => a.meta.mode === 'QUARTERLY') ? 'QUARTERLY' : 'ANNUAL';
}
- /** The declaration, but only when it was filed for the year being shown. */
- private declarationForSelectedFy(): Declaration | null {
- if (!this.declarationOnRecord || !this.fy) {
+ private loadPlatformYear(year: string): void {
+ this.tdsService
+ .getDepositInterest(Number(year.slice(0, 4)), Number(this.clientId))
+ .pipe(
+ catchError(() => of([])),
+ takeUntil(this.destroy$)
+ )
+ .subscribe((rows) => {
+ this.platformByFy.set(year, rows.find((r) => `${r.clientId}` === `${this.clientId}`) ?? null);
+ this.recompute();
+ });
+ }
+
+ /** The live declaration recorded for the year being shown, if any. */
+ get declarationRecord(): TdsDeclarationRecord | null {
+ if (!this.fy) {
return null;
}
const year = normaliseFy(this.fy);
- if (this.declarationFy && normaliseFy(this.declarationFy) !== year) {
+ return this.declarations.find((d) => d.fy === year && !d.revoked) ?? null;
+ }
+
+ get declarationOnRecord(): boolean {
+ return !!this.declarationRecord;
+ }
+
+ /** The declaration as the engine takes it — operative from the day it was accepted. */
+ private declarationForSelectedFy(): Declaration | null {
+ const d = this.declarationRecord;
+ if (!d) {
return null;
}
- return {
- kind: this.declarationKind,
- // No acceptance date on record — treated as covering the whole year.
- acceptedOn: `${year.slice(0, 4)}-04-01`,
- fy: year
- };
+ const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(this.dateOfBirth, d.fy);
+ return { kind, acceptedOn: d.acceptedOn, fy: d.fy, uin: d.uin || undefined };
+ }
+
+ openDeclarationForm(): void {
+ this.newDeclaration = { acceptedOn: new Date().toISOString().slice(0, 10), uin: '', estimatedIncome: null };
+ this.declarationError = '';
+ this.showDeclarationForm = true;
+ }
+
+ saveDeclaration(): void {
+ if (!this.newDeclaration.acceptedOn) {
+ this.declarationError = 'Enter the date the branch accepted the form.';
+ return;
+ }
+ this.savingDeclaration = true;
+ this.declarationError = '';
+ this.tdsService
+ .createDeclaration({
+ clientId: Number(this.clientId),
+ fy: normaliseFy(this.fy),
+ formType: declarationFormFor(this.fy, this.dateOfBirth),
+ acceptedOn: this.newDeclaration.acceptedOn,
+ uin: this.newDeclaration.uin || undefined,
+ estimatedIncome: this.newDeclaration.estimatedIncome
+ })
+ .subscribe({
+ next: (rows) => {
+ this.savingDeclaration = false;
+ this.showDeclarationForm = false;
+ this.declarations = rows || [];
+ this.recompute();
+ },
+ error: (err: any) => {
+ this.savingDeclaration = false;
+ this.declarationError =
+ err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.';
+ }
+ });
+ }
+
+ revokeDeclaration(): void {
+ const d = this.declarationRecord;
+ if (!d) {
+ return;
+ }
+ const reason = prompt('Why is this declaration being withdrawn? From today, TDS will be deducted again.');
+ if (!reason) {
+ return;
+ }
+ this.tdsService.revokeDeclaration(d.id, reason).subscribe({
+ next: (rows) => {
+ this.declarations = rows || [];
+ this.recompute();
+ },
+ error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not revoke the declaration.')
+ });
}
// ───────────────────────── Template helpers ─────────────────────────
@@ -474,11 +575,12 @@ export class Form121TabComponent implements OnInit, OnDestroy {
}
/**
- * A declaration is on record but deposit accounts are still set to withhold —
- * the branch has not switched them off.
+ * A declaration is on record but a deposit still carries a stock Fineract tax group. Those
+ * accounts withhold on their own rules and never consult the declaration record, so the form
+ * does not stop their deduction until the tax group is removed.
*/
get declarationNotAppliedToAccounts(): boolean {
- return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => a.withHoldTax);
+ return !!this.declarationForSelectedFy() && this.liableAccounts.some((a) => !!a.taxGroup);
}
/**
diff --git a/src/app/core/http/cache.interceptor.ts b/src/app/core/http/cache.interceptor.ts
index c58b0a4a0..d20aca013 100644
--- a/src/app/core/http/cache.interceptor.ts
+++ b/src/app/core/http/cache.interceptor.ts
@@ -36,6 +36,10 @@ export class CacheInterceptor implements HttpInterceptor {
'/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.
diff --git a/src/app/core/shell/toolbar/toolbar.component.html b/src/app/core/shell/toolbar/toolbar.component.html
index 947cb6e68..37321f09d 100644
--- a/src/app/core/shell/toolbar/toolbar.component.html
+++ b/src/app/core/shell/toolbar/toolbar.component.html
@@ -20,7 +20,7 @@
LIVE
-
Branch: HO
+
Branch: {{ officeLabel }}
Date: {{ today | date: 'dd MMM yyyy' }}
diff --git a/src/app/core/shell/toolbar/toolbar.component.ts b/src/app/core/shell/toolbar/toolbar.component.ts
index d142b6df4..7dddd1174 100644
--- a/src/app/core/shell/toolbar/toolbar.component.ts
+++ b/src/app/core/shell/toolbar/toolbar.component.ts
@@ -49,6 +49,7 @@ import { MatIcon } from '@angular/material/icon';
import { NotificationsTrayComponent as NotificationsTrayComponent_1 } from '../../../shared/notifications-tray/notifications-tray.component';
import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module';
import { DocumentationLinksService } from 'app/shared/services/documentation-links.service';
+import { businessToday } from 'app/core/utils/business-today';
/**
* Toolbar component.
@@ -92,6 +93,14 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
@ViewChild('templateAppMenu') templateAppMenu: TemplateRef;
@ViewChild('notificationsTray') notificationsTray: NotificationsTrayComponent;
+ /** The signed-in user's office — this chip read "HO" for every login, branch staff included. */
+ get officeLabel(): string {
+ if (this.authenticationService.isHeadOfficeUser()) {
+ return 'HO';
+ }
+ return this.authenticationService.getCredentials()?.officeName || '—';
+ }
+
/** Subscription to breakpoint observer for handset. */
isHandset$: Observable = this.breakpointObserver
.observe(Breakpoints.Handset)
@@ -100,8 +109,10 @@ export class ToolbarComponent implements OnInit, AfterViewInit, AfterContentChec
/** Sets the initial state of sidenav as collapsed. Not collapsed if false. */
sidenavCollapsed = true;
- /** Current date for top bar display */
- today = new Date();
+ /** The bank's "today" for the top bar — the business date when enabled, else the server date. */
+ get today(): Date {
+ return businessToday();
+ }
/** Last login time display */
lastLoginTime = this.formatLastLogin();
diff --git a/src/app/core/utils/business-today.ts b/src/app/core/utils/business-today.ts
new file mode 100644
index 000000000..c39ffe0e6
--- /dev/null
+++ b/src/app/core/utils/business-today.ts
@@ -0,0 +1,30 @@
+/**
+ * Copyright since 2025 Mifos Initiative
+ *
+ * This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/.
+ */
+
+/**
+ * The bank's "today": the business date while `enable-business-date` is on, the server's own date
+ * while it is off. The server decides which (GET /branchday/today); the web app stores the answer
+ * in `mifosXServerDate` at login and whenever the business date changes. Before that has happened
+ * the browser clock is the only date there is, so it is the fallback.
+ */
+export function businessToday(): Date {
+ const stored = localStorage.getItem('mifosXServerDate');
+ const match = stored ? /^(\d{4})-(\d{2})-(\d{2})$/.exec(stored) : null;
+ if (!match) {
+ return new Date();
+ }
+ // Built from parts so it is local midnight — new Date('yyyy-MM-dd') would be UTC midnight and
+ // land on the previous day in any timezone behind UTC.
+ return new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3]));
+}
+
+/** {@link businessToday} as yyyy-MM-dd. */
+export function businessTodayIso(): string {
+ const d = businessToday();
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+}
diff --git a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html
index cf231ab89..20d63ccac 100644
--- a/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html
+++ b/src/app/home/dashboard/crr-slr-dialog/crr-slr-dialog.component.html
@@ -18,9 +18,16 @@
NDTL · CRR/SLR Computation Base · As per RBI Master Circular
+ @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..4b92acef6 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,40 @@ 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/organization/rbi-reserve-config/rbi-reserve-config.component.html b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html
index 524a379c8..46124ece9 100644
--- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html
+++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.html
@@ -52,7 +52,7 @@
}
} @else {
Not set
- {{ type === 'CRR' ? 'Form 1' : 'Form 9' }} cannot compute
+ {{ type === 'CRR' ? 'Form 1 Part B' : 'Form 1 Part C' }} cannot compute
}
}
@@ -62,8 +62,10 @@
Rate
- CRR — Form 1
- SLR — Form 9
+
+ CRR — Form 1 Part B
+ SLR — Form 1 Part CSavings — demand share
diff --git a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
index 8fea368ae..4edfb90f0 100644
--- a/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
+++ b/src/app/organization/rbi-reserve-config/rbi-reserve-config.component.ts
@@ -23,8 +23,11 @@ export interface ReserveMapping {
}
/**
- * Configuration behind RBI Form 1 (CRR) and Form 9 (SLR): the percentages RBI sets, and which GL
- * accounts the returns read their figures from.
+ * Configuration behind RBI Form 1: the percentages RBI sets, and which GL accounts the return reads
+ * its figures from.
+ *
+ * One form settles both ratios. Part B is section 18 (the cash reserve) and Part C is section 24
+ * (the liquid assets), charged on the same base, so CRR and SLR are configured together here.
*
* Both halves sit on one screen because they are one decision. A percentage applies to a base, and
* an administrator who sets the rate but never maps the accounts produces a return that looks
@@ -47,8 +50,8 @@ export class RbiReserveConfigComponent implements OnInit {
private organizationService = inject(OrganizationService);
/**
- * Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1
- * and Form 9, 'OSS1', 'OSS2'. Null shows every role.
+ * Which return's roles this screen configures, as the backend groups them: 'RESERVE' for Form 1,
+ * 'FORM9' for Form IX, 'OSS1', 'OSS2'. Null shows every role.
*
* A screen per return rather than one screen for all of them: the four roles that drive CRR and
* the thirty that do not are separate decisions, made by different people at different times,
@@ -65,13 +68,14 @@ export class RbiReserveConfigComponent implements OnInit {
roleGroups: { name: string; label: string; roles: any[] }[] = [];
/**
- * Which return's configuration is on screen: Form 1, Form 9, or both together.
+ * Which return's configuration is on screen: Form 1, Form IX, or both together.
*
- * The two returns share a base but maintain their requirement in different assets, so an
- * administrator setting one up is looking at a different set of heads than one setting up the
- * other. Showing all twenty-six at once buries the four that matter for the task in hand.
+ * The two returns have nothing in common - Form 1 charges ratios on a liability base, Form IX
+ * reports a balance sheet - so an administrator setting one up is looking at a different set of
+ * heads than one setting up the other. Showing all of them at once buries the handful that matter
+ * for the task in hand.
*/
- formFilter: 'ALL' | 'FORM_1' | 'FORM_9' | 'FORM_IX' = 'ALL';
+ formFilter: 'ALL' | 'FORM_1' | 'FORM_IX' = 'ALL';
/** Every reserve role the backend returned, before the form filter narrows it. */
private allRoles: any[] = [];
@@ -95,20 +99,28 @@ export class RbiReserveConfigComponent implements OnInit {
return scoped.filter((r: any) => this.belongsToSelectedForm(r));
}
+ /**
+ * The returns this bank files, and the only ones that can be configured.
+ *
+ * There is no separate SLR return. Form 1 settles both ratios on one form - Part B is section 18,
+ * the cash reserve, and Part C is section 24, the liquid assets - so the heads that meet SLR are
+ * Form 1's heads. A "Form 9 - SLR" option used to sit here from before that was understood; it
+ * selected a return that has no report behind it, and filtered the SLR heads out of the one form
+ * that actually reports them.
+ */
readonly formFilters = [
{ value: 'ALL', label: 'All returns' },
- { value: 'FORM_1', label: 'Form 1 — CRR (fortnightly)' },
- { value: 'FORM_9', label: 'Form 9 — SLR (monthly)' },
+ { value: 'FORM_1', label: 'Form 1 — CRR & SLR' },
{ value: 'FORM_IX', label: 'Form IX — Assets & Liabilities (R408)' }
];
/**
* Whether a head belongs to the return currently selected.
*
- * Decided from how the return consumes the head rather than from its name: MAINTAINED is met in
- * cash and balances and so is Form 1's, SLR_ASSET is met in securities and gold and so is Form
- * 9's, and a head that builds or nets the DTL base belongs to both because both charge their rate
- * on that same base. A memo head is stated on either return and so shows on both.
+ * Form 1 takes every head that is not Form IX's. It reports both ratios: MAINTAINED heads are met
+ * in cash and balances and settle Part B, SLR_ASSET heads are met in securities and gold and
+ * settle Part C, and the heads that build or net the DTL base feed the IV(b) both rates are
+ * charged on. Splitting them across two filters only hid half of one form.
*/
private belongsToSelectedForm(role: any): boolean {
if (this.formFilter === 'ALL') {
@@ -125,19 +137,7 @@ export class RbiReserveConfigComponent implements OnInit {
if (this.formFilter === 'FORM_IX') {
return false;
}
- // The built-in SLR investments role predates the SLR_ASSET usage and still carries MAINTAINED,
- // so it is placed by name; every other head is placed by how the return consumes it.
- if (role.configKey === 'SLR_INVESTMENTS') {
- return this.formFilter === 'FORM_9';
- }
- const usage = role.usage || role.roleUsage;
- if (usage === 'MAINTAINED') {
- return this.formFilter === 'FORM_1';
- }
- if (usage === 'SLR_ASSET') {
- return this.formFilter === 'FORM_9';
- }
- // LIABILITY, ASSET_NET and MEMO all appear on both returns.
+ // Everything left belongs to Form 1, whichever of its two parts it settles.
return true;
}
@@ -171,15 +171,15 @@ export class RbiReserveConfigComponent implements OnInit {
/**
* How a head an administrator adds is consumed by the return.
*
- * MAINTAINED and SLR_ASSET are separate because the two returns admit different assets: CRR is
- * met in cash and balances with banks, SLR in cash, gold and approved securities. One option for
- * both would let a head meant for Form 1 quietly satisfy Form 9.
+ * MAINTAINED and SLR_ASSET are separate because the two parts admit different assets: CRR is met
+ * in cash and balances with banks, SLR in cash, gold and approved securities. One option for both
+ * would let a head meant for Part B quietly satisfy Part C.
*/
readonly roleUsages = [
- { value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1 & 9)' },
- { value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1 & 9)' },
- { value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1)' },
- { value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 9)' },
+ { value: 'LIABILITY', label: 'Liability — adds to the DTL base (Form 1)' },
+ { value: 'ASSET_NET', label: 'Asset — netted off the base to give NDTL (Form 1)' },
+ { value: 'MAINTAINED', label: 'Counts toward CRR maintained (Form 1 Part B)' },
+ { value: 'SLR_ASSET', label: 'Counts toward SLR maintained (Form 1 Part C)' },
// Form IX has no ratio and no base: its usages say which side of the balance sheet a head sits
// on. LIABILITY above serves its liability side, and this its asset side.
{ value: 'ASSET', label: 'Asset — enters Total Assets (Form IX)' },
@@ -282,9 +282,9 @@ export class RbiReserveConfigComponent implements OnInit {
get screenSubtitle(): string {
return (
- 'The CRR and SLR percentages, and the GL accounts that RBI Form 1 (CRR, fortnightly) and Form 9 (SLR, monthly) ' +
- 'read their figures from. A rate applies to a base — so a role left unmapped stops its return computing rather ' +
- 'than reporting a nil figure that looks real.'
+ 'The CRR and SLR percentages, and the GL accounts RBI Form 1 reads its figures from — Part B settles the cash ' +
+ 'reserve, Part C the liquid assets, both charged on the same base. A rate applies to a base — so a role left ' +
+ 'unmapped stops the return computing rather than reporting a nil figure that looks real.'
);
}
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.service.ts b/src/app/rbi-circulars/rbi-circulars.service.ts
index ed6e10638..7bffbc36f 100644
--- a/src/app/rbi-circulars/rbi-circulars.service.ts
+++ b/src/app/rbi-circulars/rbi-circulars.service.ts
@@ -177,7 +177,9 @@ export class RbiCircularsService {
reportingDate?: string,
firstColumnDate?: string,
secondColumnDate?: string,
- officeId?: number
+ officeId?: number,
+ firstBaseDate?: string,
+ secondBaseDate?: string
): Observable {
let params = new HttpParams();
if (reportingDate) {
@@ -192,6 +194,15 @@ export class RbiCircularsService {
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));
}
diff --git a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
index a28486f4e..b275460dd 100644
--- a/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
+++ b/src/app/rbi-circulars/reserve-return/form1-crr.component.ts
@@ -68,7 +68,10 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
return this.rbiService.getForm1Statutory(
this.statutoryDate,
this.statutoryFirstColumn || undefined,
- this.statutorySecondColumn || undefined
+ this.statutorySecondColumn || undefined,
+ undefined,
+ this.statutoryFirstBase || undefined,
+ this.statutorySecondBase || undefined
);
}
@@ -112,12 +115,18 @@ export class Form1CrrComponent extends ReserveReturnBase implements OnInit {
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, so they are derived from the reporting date instead. */
+ /** 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 {
diff --git a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
index 65c05639d..b4cd0e3d4 100644
--- a/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
+++ b/src/app/rbi-circulars/reserve-return/form1-statutory.component.scss
@@ -78,18 +78,38 @@ $red: #c5221f;
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;
@@ -248,6 +268,18 @@ $red: #c5221f;
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.
@@ -307,6 +339,107 @@ $red: #c5221f;
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;
diff --git a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
index 3fdbea146..8d7727fd9 100644
--- a/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.base.ts
@@ -10,7 +10,7 @@ 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, newWorkbook, solidFill, XL } from '../oss-workbook';
+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 {
@@ -97,13 +97,72 @@ export interface Form1Part {
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 two fortnight closes and the reporting date, in time order. */
+ /** 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;
@@ -364,8 +423,8 @@ export abstract class ReserveReturnBase {
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 August that is 15/08 and 31/08 — and charges both ratios on the last Friday ' +
- 'of the second preceding fortnight.';
+ '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 ──
//
@@ -388,6 +447,75 @@ export abstract class ReserveReturnBase {
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);
@@ -397,7 +525,16 @@ export abstract class ReserveReturnBase {
generateStatutory(): void {
if (this.statutoryTakesTwoColumns) {
if (!this.statutoryFirstColumn || !this.statutorySecondColumn) {
- this.statutoryError = 'Enter both fortnight closing dates — for August that is 15/08 and 31/08.';
+ 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) {
@@ -410,6 +547,16 @@ export abstract class ReserveReturnBase {
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;
@@ -1166,12 +1313,11 @@ export abstract class ReserveReturnBase {
`;
/**
- * The statutory form as a workbook, laid out as the form is printed.
+ * The statutory form as the workbook RBI files it in — the CIMS R377 template.
*
- * This is the form that gets filed, so it has to leave the screen in a shape a filer can hand on:
- * the same three dated columns, the same Roman-numbered lines, the same parts in the same order.
- * A workbook that reorganised the form would have to be read back against the screen line by line
- * before anyone could trust it.
+ * 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) {
@@ -1180,94 +1326,584 @@ export abstract class ReserveReturnBase {
this.generating = true;
try {
const wb = newWorkbook();
- const sheet = wb.addWorksheet(this.statutoryTabLabel);
- sheet.columns = [
- { width: 64 },
- { width: 20 },
- { width: 20 },
- { width: 20 }];
-
- addBanner(sheet, this.statutory.formName, 'D');
-
- const ref = sheet.addRow([this.statutory.statutoryReference]);
- sheet.mergeCells(ref.number, 1, ref.number, 4);
- ref.font = { size: 9, italic: true };
- ref.alignment = { horizontal: 'center' };
- sheet.addRow([]);
-
- let start = sheet.rowCount + 1;
- this.addKeyValue(sheet, 'Made up to', this.statutory.reportingDate);
- this.addKeyValue(
- sheet,
- 'CRR rate',
- this.statutory.crrPercentage === null ? 'Not set' : `${this.statutory.crrPercentage}%`
- );
- this.addKeyValue(
- sheet,
- 'SLR rate',
- this.statutory.slrPercentage === null ? 'Not set' : `${this.statutory.slrPercentage}%`
- );
- boxAllCells(sheet, start, sheet.rowCount, 2);
- sheet.addRow([]);
-
- // Anything that would make the form wrong to file goes above the figures, not below them:
- // a caveat under a full statement is read after the reader has already believed it.
- if (this.statutory.unmetRequirements.length) {
- const warn = sheet.addRow(['Check before filing:']);
- warn.font = { bold: true, color: { argb: 'FFB06000' } };
- for (const reason of this.statutory.unmetRequirements) {
- const r = sheet.addRow([` ${reason}`]);
- r.font = { size: 9, color: { argb: 'FFB06000' } };
- }
- sheet.addRow([]);
- }
-
- start = sheet.rowCount + 1;
- // The third column is the base the ratios are charged on, not a third close — said on the
- // header so the workbook cannot be read as carrying three reporting dates.
- const head = sheet.addRow([
- 'Particulars',
- ...this.statutory.columns.map((col, i) => `${col}\n${i === 2 ? 'base for ratios' : 'fortnight close'}`)
- ]);
- head.font = { bold: true };
- head.alignment = { wrapText: true, horizontal: 'center' };
- head.eachCell((c) => (c.fill = solidFill(XL.headerGrey)));
-
- for (const part of this.statutoryParts) {
- const banner = sheet.addRow([part.title]);
- sheet.mergeCells(banner.number, 1, banner.number, 4);
- banner.font = { bold: true, color: { argb: XL.bannerText } };
- banner.eachCell((c) => (c.fill = solidFill(XL.banner)));
-
- for (const line of part.lines) {
- // The form's own indentation carries its numbering — (a), (i), (A) — so a flattened
- // sheet would lose which line belongs under which head.
- const row = sheet.addRow([
- `${' '.repeat(line.indent ?? 0)}${line.label}`,
- ...(line.values ?? []).map((v) => v)
- ]);
- row.getCell(1).alignment = { wrapText: true };
- if (line.heading || line.total) {
- row.font = { bold: true };
- }
- for (let c = 2; c <= 4; c++) {
- row.getCell(c).numFmt = '#,##0.00';
- row.getCell(c).alignment = { horizontal: 'right' };
- }
- }
-
- this.addStatutoryTotals(sheet, 'Required', part.required);
- this.addStatutoryTotals(sheet, 'Maintained', part.maintained);
- this.addStatutoryTotals(sheet, 'Surplus / (shortfall)', part.surplus);
- }
- boxAllCells(sheet, start, sheet.rowCount, 4);
-
- await downloadWorkbook(wb, `${this.exportStem}_${this.statutory.reportingDate}`);
+ 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.
*
@@ -1276,6 +1912,104 @@ export abstract class ReserveReturnBase {
* 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) {
@@ -1286,7 +2020,9 @@ export abstract class ReserveReturnBase {
...values
]);
row.font = { bold: true };
- for (let c = 2; c <= 4; c++) {
+ // 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
index 9495bf0d5..5202e731a 100644
--- a/src/app/rbi-circulars/reserve-return/reserve-return.component.html
+++ b/src/app/rbi-circulars/reserve-return/reserve-return.component.html
@@ -534,12 +534,51 @@
heading, so the picker does not offer one. -->
First column
-
+
Second column
-
+
+
+
+
+