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.
} @@ -192,20 +192,90 @@ } - @if (declarationOnRecord && declarationFy) { + @if (declarationRecord; as d) {
Declared for - FY {{ declarationFy }} + FY {{ d.fy }} · Form {{ d.formType }}
- } - @if (declarationOnRecord && !acceptanceDateKnown) { -
- Acceptance date +
+ 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 (d.uin) { +
+ UIN + {{ d.uin }} +
+ } +
+ + + + +
+ } @else if (fy && !configMissing) { +
+ + + @if (!showDeclarationForm) { + + @if (!panWellFormed) { +
A valid PAN must be on the KYC first — a declaration without one is void.
+ } + } +
+
+ @if (showDeclarationForm) { +
+ + + + @if (declarationError) { +
+ error_outline +
{{ declarationError }}
+
+ } +
+ + +
+
+ } + } + @for (old of declarations; track old.id) { + @if (old.revoked && old.fy === fy) { +
+ Withdrawn + + Form {{ old.formType }} accepted {{ old.acceptedOn | date: 'd MMM y' }}, withdrawn + {{ old.revokedOn | date: 'd MMM y' }} — {{ old.revokeReason }} + +
+ } } @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

+ +
+ + {{ unavailable }} +
+
+
- CRR Required (3%) - ₹2,077 Cr + CRR Required{{ crrSlr.crrRate === null ? '' : ' (' + crrSlr.crrRate + '%)' }} + {{ crrSlr.crrRequired }}
- SLR Required (18%) - ₹12,460 Cr + SLR Required{{ crrSlr.slrRate === null ? '' : ' (' + crrSlr.slrRate + '%)' }} + {{ crrSlr.slrRequired }}
-
Within Limits
+ +
+ @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 C Savings — 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 - + + + + +
+ + Base for first column + + + + Base for second column + + +
+ + +
- @if (statutoryFirstColumn && statutorySecondColumn) {

Made up to {{ statutorySecondColumn }}. Both ratios are charged on the last Friday of the second preceding fortnight, stated on the form - itself. + >. The base dates above are the matching close one month earlier — change either + one if the bank files on a different base.

} @else { -

Enter both fortnight closing dates to run the form.

+

Enter both fortnight closing dates — the base dates fill in from them.

} @@ -586,7 +622,7 @@ @if (statutoryDate) {

The return reports the month ending on or before {{ statutoryDate }}, and charges the ratio on the last Friday of the second preceding fortnight. + >, and charges the ratio on the matching close one month earlier.

} } @@ -642,13 +678,17 @@ Particulars - + @for (col of statutory.columns; track col; let i = $index) { {{ col }} - {{ i === 2 ? 'base for ratios' : 'fortnight close' }} + fortnight close + @if (statutory.baseColumns?.[i]) { + charged on {{ statutory.baseColumns[i] }} + } } @@ -679,7 +719,7 @@ requirement hangs off those dates, so they are printed rather than inferred. --> @if (line.baseDates?.length) { - Base dates — last Friday of the second preceding fortnight + Base dates — the matching close one month earlier @for (d of line.baseDates; track $index) { {{ d }} } @@ -703,6 +743,104 @@
AUTHORISED SIGNATORY
+ + + @if (statutory.daily?.days?.length) { +
+

+ Day by day — {{ statutory.daily!.from }} to {{ statutory.daily!.to }} + @if (statutory.daily!.crrShortDays || statutory.daily!.slrShortDays) { + + @if (statutory.daily!.crrShortDays) { + CRR short on {{ statutory.daily!.crrShortDays }} of {{ statutory.daily!.dayCount }} days. + } + @if (statutory.daily!.slrShortDays) { + SLR short on {{ statutory.daily!.slrShortDays }} of {{ statutory.daily!.dayCount }} days. + } + + } +

+

+ Each day is charged on its own base, which moves at the fortnight boundary. A shortfall shows as a negative. +

+ +
+
+ CRR average held + {{ amount(statutory.daily!.crrAverage) }} + + lowest {{ amount(statutory.daily!.crrLowest) }} on {{ statutory.daily!.crrLowestOn }} + +
+
+ SLR average held + {{ amount(statutory.daily!.slrAverage) }} + + lowest {{ amount(statutory.daily!.slrLowest) }} on {{ statutory.daily!.slrLowestOn }} + +
+
+ Days counted + {{ statutory.daily!.dayCount }} + to {{ statutory.daily!.to }} +
+
+ + + + + + + + + + + + + + + + + + + + + + + + @for (day of statutory.daily!.days; track day.date) { + + + + + + + + + + + + + + } + +
DateNet demand & time liabilitiesSection 18 — CRRSection 24 — SLR
On the dayCharged onBase amountRequiredMaintainedSurplus / (short)RequiredMaintainedSurplus / (short)
{{ day.date }} + {{ day.ownLiabilities === undefined ? '—' : amount(day.ownLiabilities) }} + {{ day.baseDate }} + {{ day.baseLiabilities === undefined ? '—' : amount(day.baseLiabilities) }} + {{ amount(day.crrRequired) }}{{ amount(day.crrMaintained) }}{{ amount(day.crrSurplus) }}{{ amount(day.slrRequired) }}{{ amount(day.slrMaintained) }}{{ amount(day.slrSurplus) }}
+
+ } } } @@ -831,10 +969,18 @@ Particulars + @for (col of saved.columns; track col; let i = $index) { {{ col }} - {{ i === 2 ? 'base for ratios' : 'fortnight close' }} + + {{ saved.columns.length > 2 && i === 2 ? 'base for ratios' : 'fortnight close' }} + + @if (saved.baseColumns?.[i]) { + charged on {{ saved.baseColumns[i] }} + } } diff --git a/src/app/system/system.service.ts b/src/app/system/system.service.ts index b5e69a37d..c93cf2564 100644 --- a/src/app/system/system.service.ts +++ b/src/app/system/system.service.ts @@ -421,6 +421,14 @@ export class SystemService { return this.http.get(`/businessdate/${dateType}`); } + /** + * @returns {Observable<{ today: string }>} The bank's "today" (yyyy-MM-dd): the business date while + * enable-business-date is on, the server's date while it is off. Readable by any signed-in user. + */ + getBusinessToday(): Observable<{ today: string }> { + return this.http.get<{ today: string }>('/branchday/today'); + } + /** * @param {any} dateData Business Date data to be updated. * @returns {Observable} diff --git a/src/app/tds-reports/tds-interest-export.spec.ts b/src/app/tds-reports/tds-interest-export.spec.ts new file mode 100644 index 000000000..0b4335614 --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.spec.ts @@ -0,0 +1,138 @@ +/** + * @jest-environment-options {"customExportConditions": ["node", "node-addons"]} + */ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; +import { buildTdsInterestWorkbook } from './tds-interest-export'; +import { TdsDeductee, setTdsDeductees } from './tds-reports-data'; +import { tdsInterestRows, tdsInterestSummary } from './tds-reports.compute'; + +installTdsConfigFixture(); + +const FY = '2025-26'; +const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; + +const BOOK: TdsDeductee[] = [ + { + clientId: 1, + customerId: 'C1', + name: 'Ramesh', + pan: 'ABCPR1234K', + dob: '1980-05-01', + branchCode: 'Head Office', + declarations: {}, + accounts: [ + { + id: 11, + accountNo: 'FD11', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 1, + credits: Q.map((date) => ({ date, amount: 15_000 })), + deductions: [{ date: Q[3], amount: 6_000 }] + }, + { + id: 12, + accountNo: 'SB12', + depositTypeCode: 'depositAccountType.savingsDeposit', + clientId: 1, + credits: [{ date: Q[1], amount: 1_200 }], + deductions: [] + } + ] + }, + { + clientId: 4, + customerId: 'C4', + name: 'Kamala', + pan: 'ABCPK9012M', + dob: '1979-07-07', + branchCode: 'bhopal', + declarations: { [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } }, + accounts: [ + { + id: 41, + accountNo: 'FD41', + depositTypeCode: 'depositAccountType.fixedDeposit', + clientId: 4, + credits: Q.map((date) => ({ date, amount: 20_000 })), + deductions: [] + } + ] + } +]; + +function build() { + setTdsDeductees(BOOK); + const filter = { + fy: FY, + branchCode: '', + quarter: 0 as const, + band: '' as const, + search: '', + crossedOnly: false, + nearThresholdOnly: false + }; + const rows = tdsInterestRows(filter); + return buildTdsInterestWorkbook({ + fy: FY, + scopeLabel: 'All branches', + rules: { general: 50_000, senior: 100_000, rate: 10, rateNoPan: 20 }, + statement: 'Form 26Q', + rows, + declaredRows: rows.filter((r) => r.hasDeclaration), + summary: tdsInterestSummary(filter), + statusOf: () => ({ label: 'status' }) + }); +} + +describe('TDS register workbook', () => { + it('has the four sheets a reader expects, in order', () => { + expect(build().worksheets.map((w) => w.name)).toEqual(['Summary', 'Customers', 'Accounts', 'Form 121 given']); + }); + + it('lists one row per customer, with the figures as numbers and a live total', () => { + const ws = build().getWorksheet('Customers')!; + expect(ws.getRow(3).getCell(2).value).toBe('Customer name'); + const at = [4, 5].find((n) => ws.getRow(n).getCell(2).value === 'Ramesh')!; + const ramesh = ws.getRow(at); + expect(ramesh.getCell(2).value).toBe('Ramesh'); + expect(ramesh.getCell(7).value).toBe(60_000); + expect(ramesh.getCell(7).numFmt).toContain('#,##0.00'); + expect(ramesh.getCell(11).value).toBe(6_000); + expect(ramesh.getCell(12).value).toBe(6_000); + expect(ramesh.getCell(13).value).toEqual({ formula: `K${at}-L${at}` }); + const total = ws.getRow(6); + expect(total.getCell(2).value).toBe('Total'); + expect(total.getCell(7).value).toEqual({ formula: 'SUM(G4:G5)' }); + }); + + it('lists every account behind the figures, saying which ones count', () => { + const ws = build().getWorksheet('Accounts')!; + const byAccount = new Map([4, 5, 6].map((n) => [ws.getRow(n).getCell(4).value, ws.getRow(n)])); + expect([...byAccount.keys()].sort()).toEqual(['FD11', 'FD41', 'SB12']); + expect(byAccount.get('SB12')!.getCell(6).value).toBe('No'); + expect(byAccount.get('FD11')!.getCell(6).value).toBe('Yes'); + expect(byAccount.get('FD11')!.getCell(10).value).toBeInstanceOf(Date); + }); + + it('puts the declarants on their own sheet with the TDS the form saved', () => { + const ws = build().getWorksheet('Form 121 given')!; + const kamala = ws.getRow(4); + expect(kamala.getCell(2).value).toBe('Kamala'); + expect(kamala.getCell(7).value).toBe('UIN0001'); + expect(kamala.getCell(10).value).toBe(40_000); + expect(kamala.getCell(11).value).toBe(4_000); + }); + + it('writes a file Excel can open', async () => { + const buffer = await build().xlsx.writeBuffer(); + expect(buffer.byteLength).toBeGreaterThan(5_000); + }); +}); diff --git a/src/app/tds-reports/tds-interest-export.ts b/src/app/tds-reports/tds-interest-export.ts new file mode 100644 index 000000000..2a55151fe --- /dev/null +++ b/src/app/tds-reports/tds-interest-export.ts @@ -0,0 +1,379 @@ +/** + * Copyright since 2025 Mifos Initiative + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ + +import * as ExcelJS from 'exceljs'; + +import { TdsAccountLine, TdsInterestRow, TdsInterestSummary } from './tds-reports.compute'; + +/** What the workbook needs from the screen, so it prints exactly what the user is looking at. */ +export interface TdsExportInput { + fy: string; + scopeLabel: string; + /** The financial-year rules, from Organization › TDS Configuration. */ + rules: { general: number; senior: number; rate: number; rateNoPan: number } | null; + /** The quarterly return the declarants' gross-paid interest is reported in. */ + statement: string; + rows: TdsInterestRow[]; + declaredRows: TdsInterestRow[]; + summary: TdsInterestSummary; + statusOf: (row: TdsInterestRow) => { label: string }; +} + +const MONEY = '"₹"#,##0.00'; +const DATE = 'dd-mmm-yyyy'; +const NAVY = 'FF1F4E79'; +const STRIPE = 'FFF3F6FA'; +const LINE = 'FFD0D7E2'; +const GREEN = 'FF15803D'; +const AMBER = 'FFB45309'; + +const ACCOUNT_TYPE: Record = { + FD: 'Fixed deposit', + RD: 'Recurring deposit', + SB: 'Savings', + OTHER: 'Other' +}; + +/** A column: its heading, width and, for figures, the cell format. */ +interface Col { + header: string; + width: number; + fmt?: string; + /** Totalled in the closing row. */ + sum?: boolean; +} + +/** + * The Deposit Interest & TDS register as a workbook a branch can read without the screen beside it: + * a one-page summary, one row per customer, one row per account, and the declarants on their own. + */ +export function buildTdsInterestWorkbook(input: TdsExportInput): ExcelJS.Workbook { + const wb = new ExcelJS.Workbook(); + wb.creator = 'CBS Nexora'; + wb.created = new Date(); + + summarySheet(wb, input); + customersSheet(wb, input); + accountsSheet(wb, input); + declarationsSheet(wb, input); + return wb; +} + +// ─────────────────────────────── Sheets ─────────────────────────────── + +function summarySheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const ws = wb.addWorksheet('Summary', { views: [{ showGridLines: false }] }); + ws.columns = [ + { width: 46 }, + { width: 22 } + ]; + title(ws, `Deposit Interest & TDS — FY ${input.fy}`, 2); + ws.addRow([`Branch: ${input.scopeLabel}`]); + ws.addRow([`Prepared on: ${new Date().toLocaleDateString('en-IN', { day: '2-digit', month: 'short', year: 'numeric' })}`]); + ws.getRow(2).font = { color: { argb: 'FF555555' } }; + ws.getRow(3).font = { color: { argb: 'FF555555' } }; + ws.addRow([]); + + const s = input.summary; + section(ws, 'Key figures'); + const figures: [string, number, string?][] = [ + ['Customers in this report', s.deductees], + [' of whom have an FD / RD being tracked', s.trackedCustomers], + ['FD / RD interest received this year', s.applicableInterest, MONEY], + ['Savings interest (shown only — no TDS)', s.exemptInterest, MONEY], + ['Customers over the TDS limit', s.crossed], + ['TDS due by the rules', s.totalTds, MONEY], + ['TDS actually deducted from accounts', s.actualTds, MONEY], + ['Difference (due − deducted) — should be 0', s.totalTds - s.actualTds, MONEY], + ['Customers with Form 121 / 15G / 15H', s.declarations], + ['TDS not deducted because of those forms', s.tdsSaved, MONEY], + ['Interest paid without TDS (report in the return)', s.paidWithoutDeduction, MONEY] + ]; + for (const [label, value, fmt] of figures) { + const r = ws.addRow([label, value]); + r.getCell(2).numFmt = fmt ?? '#,##0'; + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + // The control figure stands out when it is not zero. + const diffRow = ws.getRow(ws.rowCount - 3); + if (Math.round(s.totalTds - s.actualTds) !== 0) { + diffRow.font = { bold: true, color: { argb: AMBER } }; + } + ws.addRow([]); + + if (input.rules) { + section(ws, 'Rules for this year'); + const rules: [string, string][] = [ + ['TDS limit — below 60', rupees(input.rules.general)], + ['TDS limit — senior citizen (60+)', rupees(input.rules.senior)], + ['Rate with PAN', `${input.rules.rate}%`], + ['Rate without PAN', `${input.rules.rateNoPan}%`] + ]; + for (const [label, value] of rules) { + const r = ws.addRow([label, value]); + r.getCell(2).alignment = { horizontal: 'right' }; + boxRow(r, 2); + } + ws.addRow([]); + } + + section(ws, 'How to read this workbook'); + const notes = [ + 'Only interest from fixed and recurring deposits counts toward the limit — on the day it reaches savings, or when paid out at closure.', + "A savings account's own interest is shown for information and never attracts TDS.", + "Once a customer's interest for the year crosses the limit, TDS is due on the whole year's interest, not only the excess.", + 'A Form 121 / 15G / 15H stops TDS on interest received after the form was accepted.', + 'Sheet "Customers": one row per customer. Sheet "Accounts": every account behind those figures.', + 'Sheet "Form 121 given": customers with a declaration, and the TDS the form saved them.' + ]; + for (const n of notes) { + const r = ws.addRow([`• ${n}`]); + ws.mergeCells(r.number, 1, r.number, 2); + r.alignment = { wrapText: true, vertical: 'top' }; + r.height = 30; + } +} + +function customersSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Age group', width: 12 }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: 'TDS limit (₹)', width: 13, fmt: MONEY }, + { header: 'Over the limit?', width: 10 }, + { header: 'Rate', width: 7 }, + { header: 'TDS due (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Difference (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Form 121 given?', width: 10 }, + { header: 'Status', width: 42 } + ]; + const ws = table(wb, 'Customers', `Customers — FY ${input.fy} · ${input.scopeLabel}`, cols); + + input.rows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + r.band === 'SENIOR' ? 'Senior (60+)' : 'Below 60', + r.tdsApplicableInterest, + r.threshold, + r.crossed ? 'Yes' : 'No', + `${Math.round(r.rateApplied * 100)}%`, + r.schedule.totalTds, + r.actualTds, + null, + r.hasDeclaration ? 'Yes' : 'No', + input.statusOf(r).label + ]); + // Due − deducted as a formula, so an edited figure keeps the check honest. + line.getCell(13).value = { formula: `K${line.number}-L${line.number}` }; + styleDataRow(line, cols, i); + if (!r.deductee.pan) { + line.getCell(4).font = { color: { argb: AMBER } }; + } + if (r.crossed) { + line.getCell(9).font = { bold: true, color: { argb: AMBER } }; + } + if (Math.round(r.schedule.totalTds - r.actualTds) !== 0) { + line.getCell(13).font = { bold: true, color: { argb: AMBER } }; + } + }); + totals(ws, cols, input.rows.length); +} + +function accountsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'Account no.', width: 19 }, + { header: 'Account type', width: 17 }, + { header: 'Counted for TDS?', width: 10 }, + { header: 'Why', width: 46 }, + { header: 'Interest this year (₹)', width: 15, fmt: MONEY, sum: true }, + { header: 'Credits', width: 8 }, + { header: 'Last credited', width: 13, fmt: DATE }, + { header: 'TDS deducted (₹)', width: 13, fmt: MONEY, sum: true }, + { header: 'Account held by', width: 22 } + ]; + const ws = table(wb, 'Accounts', `Accounts — FY ${input.fy} · ${input.scopeLabel}`, cols); + + let n = 0; + for (const r of input.rows) { + for (const a of r.accounts) { + const line = ws.addRow([ + n + 1, + r.deductee.name, + r.deductee.customerId, + a.accountNo, + ACCOUNT_TYPE[a.type], + a.tracked ? 'Yes' : 'No', + a.reason, + a.interest, + a.postings, + a.lastPostedOn ? isoToDate(a.lastPostedOn) : '—', + a.tdsDeducted, + a.holderName ?? 'Same customer' + ]); + styleDataRow(line, cols, n); + if (a.tracked) { + line.getCell(6).font = { bold: true, color: { argb: GREEN } }; + } + n++; + } + } + totals(ws, cols, n); +} + +function declarationsSheet(wb: ExcelJS.Workbook, input: TdsExportInput): void { + const cols: Col[] = [ + { header: 'S.No', width: 6 }, + { header: 'Customer name', width: 26 }, + { header: 'Customer ID', width: 13 }, + { header: 'PAN', width: 13 }, + { header: 'Branch', width: 14 }, + { header: 'Form', width: 10 }, + { header: 'UIN', width: 18 }, + { header: 'Accepted on', width: 13, fmt: DATE }, + { header: 'FD/RD interest received (₹)', width: 16, fmt: MONEY, sum: true }, + { header: `Paid without TDS — report in ${input.statement} (₹)`, width: 20, fmt: MONEY, sum: true }, + { header: 'TDS not deducted because of the form (₹)', width: 18, fmt: MONEY, sum: true }, + { header: 'TDS deducted before the form (₹)', width: 16, fmt: MONEY, sum: true } + ]; + const ws = table(wb, 'Form 121 given', `Customers with Form 121 / 15G / 15H — FY ${input.fy} · ${input.scopeLabel}`, cols); + + input.declaredRows.forEach((r, i) => { + const line = ws.addRow([ + i + 1, + r.deductee.name, + r.deductee.customerId, + r.deductee.pan || 'Not on record', + r.branchCode, + `Form ${r.form}`, + r.declarationUin ?? 'Not allotted', + r.declarationAcceptedOn ? isoToDate(r.declarationAcceptedOn) : '—', + r.tdsApplicableInterest, + r.paidWithoutDeduction, + r.tdsSaved, + r.actualTds + ]); + styleDataRow(line, cols, i); + if (!r.declarationUin) { + line.getCell(7).font = { color: { argb: AMBER } }; + } + }); + if (input.declaredRows.length === 0) { + const r = ws.addRow(['', 'No customer has a declaration on record for this year.']); + r.font = { italic: true, color: { argb: 'FF777777' } }; + } else { + totals(ws, cols, input.declaredRows.length); + } +} + +// ─────────────────────────────── Styling ─────────────────────────────── + +/** A sheet with a title, a styled header row, frozen headings and a filter. */ +function table(wb: ExcelJS.Workbook, name: string, heading: string, cols: Col[]): ExcelJS.Worksheet { + const ws = wb.addWorksheet(name, { views: [{ state: 'frozen', ySplit: 3, showGridLines: false }] }); + ws.columns = cols.map((c) => ({ width: c.width })); + title(ws, heading, cols.length); + ws.addRow([]); + const head = ws.addRow(cols.map((c) => c.header)); + head.height = 34; + head.eachCell((cell) => { + cell.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + cell.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + cell.border = border(); + }); + ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: cols.length } }; + return ws; +} + +function title(ws: ExcelJS.Worksheet, text: string, span: number): void { + const r = ws.addRow([text]); + ws.mergeCells(r.number, 1, r.number, span); + r.font = { bold: true, size: 14, color: { argb: NAVY } }; + r.height = 24; +} + +function section(ws: ExcelJS.Worksheet, text: string): void { + const r = ws.addRow([text]); + r.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + r.getCell(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; + r.getCell(2).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: NAVY } }; +} + +function styleDataRow(row: ExcelJS.Row, cols: Col[], index: number): void { + cols.forEach((c, i) => { + const cell = row.getCell(i + 1); + cell.border = border(); + cell.alignment = { vertical: 'middle', wrapText: i === cols.length - 1 || c.width > 30 }; + if (c.fmt) { + cell.numFmt = c.fmt; + cell.alignment = { ...cell.alignment, horizontal: 'right' }; + } + if (index % 2 === 1) { + cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: STRIPE } }; + } + }); +} + +/** A closing row that totals the money columns with live SUM formulas. */ +function totals(ws: ExcelJS.Worksheet, cols: Col[], count: number): void { + if (count === 0) { + return; + } + const first = 4; + const last = first + count - 1; + const r = ws.addRow(cols.map((c, i) => (i === 1 ? 'Total' : null))); + cols.forEach((c, i) => { + const cell = r.getCell(i + 1); + if (c.sum) { + const letter = ws.getColumn(i + 1).letter; + cell.value = { formula: `SUM(${letter}${first}:${letter}${last})` }; + cell.numFmt = c.fmt ?? '#,##0'; + cell.alignment = { horizontal: 'right' }; + } + cell.font = { bold: true }; + cell.border = { top: { style: 'medium', color: { argb: NAVY } }, bottom: { style: 'thin', color: { argb: LINE } } }; + }); +} + +function boxRow(row: ExcelJS.Row, span: number): void { + for (let i = 1; i <= span; i++) { + row.getCell(i).border = border(); + } +} + +function border(): Partial { + const side = { style: 'thin' as const, color: { argb: LINE } }; + return { top: side, left: side, bottom: side, right: side }; +} + +function rupees(n: number): string { + return `₹${Math.round(n).toLocaleString('en-IN')}`; +} + +/** '2026-10-05' as a real date, so Excel sorts and filters it as one. */ +function isoToDate(iso: string): Date { + const [ + y, + m, + d + ] = iso.slice(0, 10).split('-').map(Number); + return new Date(Date.UTC(y, m - 1, d)); +} diff --git a/src/app/tds-reports/tds-interest-report.component.html b/src/app/tds-reports/tds-interest-report.component.html index f2e4c032e..d553115f6 100644 --- a/src/app/tds-reports/tds-interest-report.component.html +++ b/src/app/tds-reports/tds-interest-report.component.html @@ -49,8 +49,11 @@ account is which, and why.
  • - Only fixed and recurring deposits are tracked. Savings interest is shown for information but - never attracts TDS. + Only the interest from fixed and recurring deposits that reaches the customer's savings account + is tracked — counted on the day it arrives. The savings account's own interest is shown for information but + never attracts TDS. Interest still inside an FD/RD counts when it is paid out at maturity or closure — to + savings, in cash or to a GL (then the TDS comes out of the payout). A full reinvestment into a new FD/RD is not + taxed.
  • @if (rules) {
  • @@ -64,10 +67,14 @@ record.
  • } -
  • A customer who has submitted Form 15G/15H is not deducted, even over the limit.
  • - The TDS figures here are worked out by this report from the interest actually posted. They - are not debited from the customer's account automatically. + A customer who has submitted Form 15G/15H is not deducted on interest received after the form was accepted. +
  • +
  • + TDS is debited from the savings account at the moment the FD/RD interest that takes the + customer over the limit arrives, and credited to the TDS payable account. The FD/RD itself is never debited. + The TDS column shows what is due by these rules and what was actually deducted — the two + should match.
  • @@ -81,7 +88,7 @@
    ₹{{ summary.applicableInterest | number: '1.0-0' }}
    -
    FD / RD interest — counts toward the limit
    +
    FD / RD interest received in savings — counts toward the limit
    ₹{{ summary.exemptInterest | number: '1.0-0' }}
    @@ -93,7 +100,17 @@
    ₹{{ summary.totalTds | number: '1.0-0' }}
    -
    TDS to be deducted
    +
    TDS due by the rules
    +
    +
    +
    ₹{{ summary.actualTds | number: '1.0-0' }}
    +
    + Actually deducted from accounts + @if (summary.actualTds !== summary.totalTds) { + — ₹{{ summary.totalTds - summary.actualTds | number: '1.0-0' }} apart + } +
    @@ -194,7 +211,15 @@ [class.tdsr__tab--active]="selectedTab === 'paying'" (click)="selectTab('paying')" > - TDS to deduct ({{ payingRows.length }}) + TDS deducted / due ({{ payingRows.length }}) + + - + @if (isHeadOffice) { +
    + + + +
    + } - - @if (reconciled) { -
    - + @if (!isHeadOffice) { +
    +
    - Reconciled. - ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ - summary.totalDeposited | number: '1.0-0' - }} - deposited, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. + This report is for Head Office. TDS is paid to the Government by Head Office for the whole bank, + under one TAN. Your branch's customers and the TDS deducted from them are on + Reports › Deposit Interest & TDS.
    } @else { -
    - -
    - Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. - Tax deducted must equal tax deposited plus tax still within its due date. Find the difference before filing the - return. + @if (loadError) { +
    + +
    {{ loadError }}
    -
    - } + } - @if (summary.overdueChallans > 0) { -
    - -
    - {{ summary.overdueChallans }} challan{{ summary.overdueChallans === 1 ? '' : 's' }} overdue. - Interest runs at 1.5% for every month or part of a month until the tax reaches the Government. + + @if (showForm) { +
    +
    Record a challan — FY {{ fy }}
    +
    + + + + + + + +
    +
    + Saving posts Dr TDS payable / Cr the account paid from at Head Office, dated the day it was + paid. +
    + @if (formError) { +
    + +
    {{ formError }}
    +
    + } +
    + + +
    -
    - } + } - -
    -
    -
    ₹{{ summary.totalDeducted | number: '1.0-0' }}
    -
    Total TDS deducted
    -
    -
    -
    ₹{{ summary.totalDeposited | number: '1.0-0' }}
    -
    Deposited with Government
    -
    -
    -
    ₹{{ summary.pendingDeposit | number: '1.0-0' }}
    -
    Within due date
    -
    -
    -
    ₹{{ totalDefaultInterest | number: '1.0-0' }}
    -
    Interest on late deposit
    -
    -
    -
    {{ summary.deductees | number }}
    -
    Deductees in the return
    -
    -
    - - -
    -
    Part A — challan-wise remittance
    -
    - - - - - - - - - - - - - - - - @for (row of rows; track trackRow($index, row)) { - - - - - - - - - - - - } - @if (rows.length === 0) { - - - - } - -
    QuarterDeduction monthAmountDue dateDeposited onChallan · BSRStatusLate-deposit interest{{ statute.statement }} due
    - {{ quarterLabel[row.quarter] }} - {{ row.month }} - ₹{{ row.amount | number: '1.0-0' }} - {{ row.dueDate | date: 'd MMM y' }} - @if (row.depositedOn) { - {{ row.depositedOn | date: 'd MMM y' }} - } @else { - — - } - - @if (row.challanNo) { - {{ row.challanNo }} -
    BSR {{ row.bsrCode }}
    - } @else { - — - } -
    - @if (row.overdue) { - Overdue - } @else if (row.late) { - Late by {{ row.delayMonths }}m - } @else if (row.depositedOn) { - On time - } @else { - Within due date - } - - @if (row.defaultInterest) { - ₹{{ row.defaultInterest | number: '1.0-0' }} -
    1.5% × {{ row.delayMonths }}
    - } @else { - — - } -
    {{ row.returnDueDate }}
    - No tax was deducted in this financial year. -
    -
    -
    - Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. -
    -
    - - -
    -
    - Part B — {{ statute.statement }} year-end position -
    -
    -
    - Total interest paid on time deposits - ₹{{ summary.totalInterestPaid | number: '1.0-0' }} -
    -
    - with tax deducted - ₹{{ summary.interestWithDeduction | number: '1.0-0' }} -
    -
    - - without deduction — declaration on record - mandatory disclosure in {{ statute.statement }} - - ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} -
    - -
    - TDS at standard rate — PAN on record - ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} -
    -
    - TDS at higher rate — no PAN on record - ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} -
    - -
    - Total TDS deducted - ₹{{ summary.totalDeducted | number: '1.0-0' }} -
    -
    - Deposited with Government - ₹{{ summary.totalDeposited | number: '1.0-0' }} -
    -
    - Still within due date - ₹{{ summary.pendingDeposit | number: '1.0-0' }} -
    -
    - Difference — must be zero - ₹{{ summary.difference | number: '1.0-0' }} -
    - - @if (summary.defaultInterest > 0) { -
    - Interest payable by the bank for late deposit - ₹{{ summary.defaultInterest | number: '1.0-0' }} + @if (summary) { + + @if (reconciled) { +
    + +
    + Reconciled. + ₹{{ summary.totalDeducted | number: '1.0-0' }} deducted, ₹{{ summary.totalDeposited | number: '1.0-0' }} + paid to the Government, ₹{{ summary.pendingDeposit | number: '1.0-0' }} still within its due date. +
    +
    + } @else { +
    + +
    + Does not reconcile — ₹{{ summary.difference | number: '1.0-0' }} unaccounted. + Tax deducted must equal tax paid plus tax still within its due date. + @if (summary.overdueAmount > 0) { + ₹{{ summary.overdueAmount | number: '1.0-0' }} is past its due date and not yet paid. + } +
    } + + @if (summary.calculatedTds !== summary.totalDeducted) { +
    + +
    + By the TDS rules ₹{{ summary.calculatedTds | number: '1.0-0' }} was due this year, but + ₹{{ summary.totalDeducted | number: '1.0-0' }} was actually deducted from accounts. Open + Deposit Interest & TDS to see which customers differ. +
    +
    + } + + +
    +
    +
    ₹{{ summary.totalDeducted | number: '1.0-0' }}
    +
    TDS deducted from accounts
    +
    +
    +
    ₹{{ summary.totalDeposited | number: '1.0-0' }}
    +
    Paid to Government
    +
    +
    +
    ₹{{ summary.pendingDeposit | number: '1.0-0' }}
    +
    To pay — still within due date
    +
    +
    +
    ₹{{ summary.overdueAmount | number: '1.0-0' }}
    +
    Overdue — pay now
    +
    +
    +
    ₹{{ summary.defaultInterest | number: '1.0-0' }}
    +
    Interest for paying late
    +
    +
    + } + + +
    +
    Part A — month by month
    +
    + + + + + + + + + + + + + + + + + @for (row of rows; track trackRow($index, row)) { + + + + + + + + + + + + + } + @if (rows.length === 0) { + + + + } + +
    QuarterDeduction monthDeductedPay byChallansPaidStill to payStatusLate interest
    {{ quarterLabel[row.quarter] }} + {{ row.month + '-01' | date: 'MMM y' }} +
    {{ row.postings }} deduction{{ row.postings === 1 ? '' : 's' }}
    +
    ₹{{ row.deducted | number: '1.0-2' }}{{ row.dueDate | date: 'd MMM y' }} + @for (c of row.challans; track c.id) { +
    + {{ c.challanNo }} · BSR {{ c.bsrCode }} · {{ c.depositedOn | date: 'd MMM y' }} · ₹{{ + c.amount | number: '1.0-2' + }} + +
    + } @empty { + — + } +
    ₹{{ row.deposited | number: '1.0-2' }} + @if (row.outstanding) { + ₹{{ row.outstanding | number: '1.0-2' }} + } @else if (row.excess) { + ₹{{ row.excess | number: '1.0-2' }} paid extra + } @else { + — + } + + @if (row.overdue) { + Overdue + } @else if (row.late) { + Paid late by {{ row.delayMonths }}m + } @else if (row.outstanding) { + Within due date + } @else { + Paid on time + } + + @if (row.defaultInterest) { + ₹{{ row.defaultInterest | number: '1.0-0' }} + } @else { + — + } + + @if (row.outstanding) { + + } +
    + + {{ loading ? 'Loading…' : 'No TDS was deducted from any account in this financial year.' }} +
    +
    +
    + Tax deducted in a month must reach the Government by the seventh of the following month; March runs to 30 April. + Interest runs at 1.5% for every month or part of a month it is late. +
    -
    - The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the - quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of - declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current - specification before generating the file. -
    -
    + + + @if (summary) { +
    +
    + Part B — {{ statute.statement }} year-end position +
    +
    +
    + Deductees (customers with an FD/RD) + {{ summary.deductees | number }} +
    +
    + Total interest paid on time deposits + ₹{{ summary.totalInterestPaid | number: '1.0-0' }} +
    +
    + with tax deducted + ₹{{ summary.interestWithDeduction | number: '1.0-0' }} +
    +
    + + without deduction — declaration on record + mandatory disclosure in {{ statute.statement }} + + ₹{{ summary.interestWithoutDeduction | number: '1.0-0' }} +
    + +
    + TDS at standard rate — PAN on record + ₹{{ summary.tdsAtStandardRate | number: '1.0-0' }} +
    +
    + TDS at higher rate — no PAN on record + ₹{{ summary.tdsAtHigherRate | number: '1.0-0' }} +
    + +
    + Total TDS deducted + ₹{{ summary.totalDeducted | number: '1.0-0' }} +
    +
    + Paid to Government + ₹{{ summary.totalDeposited | number: '1.0-0' }} +
    +
    + Still within due date + ₹{{ summary.pendingDeposit | number: '1.0-0' }} +
    +
    + Difference — must be zero + ₹{{ summary.difference | number: '1.0-0' }} +
    + + @if (summary.defaultInterest > 0) { +
    + Interest payable by the bank for late deposit + ₹{{ summary.defaultInterest | number: '1.0-0' }} +
    + } +
    +
    + The "without deduction" line is not optional — interest paid gross against a declaration must be reported in the + quarterly {{ statute.statement }} return with the appropriate remark code, and the consolidated statement of + declarations received is uploaded {{ statute.frequency }}. Confirm the remark code against the current + specification before generating the file. +
    +
    + } + }
    diff --git a/src/app/tds-reports/tds-remittance-report.component.ts b/src/app/tds-reports/tds-remittance-report.component.ts index 9a1892cf0..d24ffc6be 100644 --- a/src/app/tds-reports/tds-remittance-report.component.ts +++ b/src/app/tds-reports/tds-remittance-report.component.ts @@ -6,17 +6,25 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { Component, inject } from '@angular/core'; +import { Component, OnInit, inject } from '@angular/core'; import { FormsModule } from '@angular/forms'; import { FaIconComponent } from '@fortawesome/angular-fontawesome'; import { STANDALONE_SHARED_IMPORTS } from 'app/standalone-shared.module'; import * as ExcelJS from 'exceljs'; +import { forkJoin } from 'rxjs'; import { BranchScopeService } from '../core/branch-scope.service'; import { FY_TAX_CONFIG, QUARTER_LABEL } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT, tdsFys } from './tds-reports-data'; +import { TdsChallan, TdsDeductedMonth, setTdsDeductees, tdsFys } from './tds-reports-data'; +import { NewTdsChallan, TdsReportsService } from './tds-reports.service'; import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } from './tds-reports.compute'; +/** + * Tax deducted against tax remitted, month by month, for Head Office. + * + * The deducted side is the withhold-tax postings on deposits; the remitted side is the challans + * entered here. Both are records — nothing on this page is worked out from a rate. + */ @Component({ selector: 'mifosx-tds-remittance-report', templateUrl: './tds-remittance-report.component.html', @@ -27,22 +35,83 @@ import { Tds26qSummary, TdsRemittanceRow, tds26qSummary, tdsRemittanceRows } fro FormsModule ] }) -export class TdsRemittanceReportComponent { +export class TdsRemittanceReportComponent implements OnInit { private scope = inject(BranchScopeService); + private tdsService = inject(TdsReportsService); /** Filled by the route resolver from Organization › TDS Configuration. */ readonly financialYears = tdsFys(); readonly quarterLabel = QUARTER_LABEL; - readonly asAt = TDS_AS_AT; fy = tdsFys()[0] ?? ''; exporting = false; + loading = false; + loadError = ''; + + /** The business date the platform is on — what "overdue" is judged against. */ + asAt = ''; + private deducted: TdsDeductedMonth[] = []; + challans: TdsChallan[] = []; + rows: TdsRemittanceRow[] = []; + summary: Tds26qSummary | null = null; + + paidFromOptions: { id: number; glCode: string; name: string }[] = []; + showForm = false; + saving = false; + formError = ''; + form: NewTdsChallan = this.blankForm(); + + /** Challans are remitted by Head Office under the bank's one TAN; a branch has nothing to enter here. */ + get isHeadOffice(): boolean { + return this.scope.isHeadOffice; + } + + ngOnInit(): void { + if (!this.isHeadOffice || !this.fy) { + return; + } + this.load(); + this.tdsService.getChallanTemplate().subscribe({ + next: (t) => (this.paidFromOptions = t?.paidFromOptions || []), + error: () => (this.paidFromOptions = []) + }); + } + + load(): void { + this.loading = true; + this.loadError = ''; + const openingYear = Number(this.fy.slice(0, 4)); + forkJoin({ + deducted: this.tdsService.getDeducted(this.fy), + challans: this.tdsService.getChallans(this.fy), + // Part B summarises the year per customer, so the interest register is loaded too. + deductees: this.tdsService.getDepositInterest(openingYear) + }).subscribe({ + next: ({ deducted, challans, deductees }) => { + this.loading = false; + this.asAt = deducted.asAt; + this.deducted = deducted.months; + this.challans = challans; + setTdsDeductees(deductees); + this.recompute(); + }, + error: (err: any) => { + this.loading = false; + this.loadError = err?.error?.defaultUserMessage || 'Could not load the remittance position.'; + } + }); + } + + private recompute(): void { + this.rows = tdsRemittanceRows(this.fy, this.deducted, this.challans, this.asAt); + this.summary = tds26qSummary(this.fy, this.rows, this.asAt); + } + + onYearChanged(): void { + this.showForm = false; + this.load(); + } - /** - * Provision and return references for the selected year. The quarterly return - * became Form 140 under the 2025 Act, so the labels are read from the config - * rather than written into the template. - */ get statute(): { section: string; statement: string; frequency: string } { const cfg = FY_TAX_CONFIG[this.fy]; return { @@ -52,53 +121,85 @@ export class TdsRemittanceReportComponent { }; } - get scopeLabel(): string { - return this.scope.isHeadOffice ? 'Bank as a whole' : this.scope.officeName; - } - - get rows(): TdsRemittanceRow[] { - return tdsRemittanceRows(this.fy, this.asAt); - } - - get summary(): Tds26qSummary { - return tds26qSummary(this.fy, this.asAt); - } - - /** - * The control that matters. Tax deducted must equal tax deposited plus tax - * still within its due date. Anything else means something has gone missing - * and must be found before the return is filed. - */ get reconciled(): boolean { - return this.summary.difference === 0; + return !!this.summary && Math.abs(this.summary.difference) < 1; } - get totalDefaultInterest(): number { - return this.summary.defaultInterest; + /** Opens the challan form, pre-filled for a month that still has tax outstanding. */ + openForm(row?: TdsRemittanceRow): void { + this.form = this.blankForm(); + if (row) { + this.form.month = row.month; + this.form.amount = row.outstanding; + } + this.formError = ''; + this.showForm = true; + } + + saveChallan(): void { + this.formError = ''; + const f = this.form; + if (!f.month || !f.amount || !f.challanNo || !f.bsrCode || !f.depositedOn || !f.paidFromGlAccountId) { + this.formError = 'Fill in every field marked *.'; + return; + } + this.saving = true; + this.tdsService.createChallan({ ...f, fy: this.fy, amount: Number(f.amount) }).subscribe({ + next: (challans) => { + this.saving = false; + this.showForm = false; + this.challans = challans; + this.recompute(); + }, + error: (err: any) => { + this.saving = false; + this.formError = err?.error?.errors?.[0]?.defaultUserMessage || err?.error?.defaultUserMessage || 'Could not save.'; + } + }); + } + + reverseChallan(c: TdsChallan): void { + if (!confirm(`Reverse challan ${c.challanNo} for ₹${c.amount}? Its accounting entry will be undone.`)) { + return; + } + this.tdsService.reverseChallan(c.id).subscribe({ + next: (challans) => { + this.challans = challans; + this.recompute(); + }, + error: (err: any) => alert(err?.error?.defaultUserMessage || 'Could not reverse the challan.') + }); + } + + private blankForm(): NewTdsChallan { + return { fy: this.fy, month: '', amount: 0, challanNo: '', bsrCode: '', depositedOn: '', paidFromGlAccountId: 0, remarks: '' }; } trackRow(_: number, row: TdsRemittanceRow): string { - return `${row.fy}-${row.quarter}`; + return row.month; } async exportWorkbook(): Promise { + const s = this.summary; + if (!s) { + return; + } this.exporting = true; try { - const s = this.summary; const wb = new ExcelJS.Workbook(); const challan = wb.addWorksheet('Challans'); challan.addRow([`TDS remitted to Government — FY ${this.fy}`]); - challan.addRow([`Scope: ${this.scopeLabel} · as at ${this.asAt}`]); + challan.addRow([`Bank as a whole · as at ${this.asAt}`]); challan.addRow([]); challan.addRow([ 'Quarter', 'Deduction month', - 'Amount deducted', + 'Deducted', 'Due date', - 'Deposited on', - 'Challan no.', - 'BSR code', + 'Deposited', + 'Challans', + 'Outstanding', 'Status', 'Delay (months)', 'Late-deposit interest', @@ -108,12 +209,12 @@ export class TdsRemittanceReportComponent { challan.addRow([ `Q${r.quarter}`, r.month, - r.amount, + r.deducted, r.dueDate, - r.depositedOn ?? 'PENDING', - r.challanNo ?? '', - r.bsrCode ?? '', - r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.depositedOn ? 'On time' : 'Within due date', + r.deposited, + r.challans.map((c) => `${c.challanNo} / BSR ${c.bsrCode} / ${c.depositedOn}`).join('; '), + r.outstanding, + r.overdue ? 'OVERDUE' : r.late ? 'LATE' : r.outstanding ? 'Within due date' : 'Deposited', r.delayMonths || '', r.defaultInterest || '', r.returnDueDate @@ -127,58 +228,19 @@ export class TdsRemittanceReportComponent { recon.addRow([`${this.statute.statement} year-end position — FY ${this.fy}`]); recon.addRow([]); const lines: [string, number | string][] = [ - [ - 'Deductees', - s.deductees - ], - [ - 'Total interest paid on time deposits', - s.totalInterestPaid - ], - [ - ' — with tax deducted', - s.interestWithDeduction - ], - [ - ' — without deduction (declaration on record)', - s.interestWithoutDeduction - ], - [ - 'TDS at standard rate (PAN on record)', - s.tdsAtStandardRate - ], - [ - 'TDS at higher rate (no PAN)', - s.tdsAtHigherRate - ], - [ - 'Total TDS deducted', - s.totalDeducted - ], - [ - 'Total deposited with Government', - s.totalDeposited - ], - [ - 'Still within due date', - s.pendingDeposit - ], - [ - 'DIFFERENCE (must be zero)', - s.difference - ], - [ - 'Interest payable for late deposit', - s.defaultInterest - ], - [ - 'Challans deposited late', - s.lateChallans - ], - [ - 'Challans overdue', - s.overdueChallans - ] + ['Deductees', s.deductees], + ['Total interest paid on time deposits', s.totalInterestPaid], + [' — with tax deducted', s.interestWithDeduction], + [' — without deduction (declaration on record)', s.interestWithoutDeduction], + ['TDS at standard rate (PAN on record)', s.tdsAtStandardRate], + ['TDS at higher rate (no PAN)', s.tdsAtHigherRate], + ['TDS due by the rules', s.calculatedTds], + ['Total TDS actually deducted', s.totalDeducted], + ['Total deposited with Government', s.totalDeposited], + ['Still within due date', s.pendingDeposit], + ['Overdue', s.overdueAmount], + ['DIFFERENCE (must be zero)', s.difference], + ['Interest payable for late deposit', s.defaultInterest] ]; for (const line of lines) { recon.addRow(line); diff --git a/src/app/tds-reports/tds-reports-data.ts b/src/app/tds-reports/tds-reports-data.ts index f64f23644..4f753f880 100644 --- a/src/app/tds-reports/tds-reports-data.ts +++ b/src/app/tds-reports/tds-reports-data.ts @@ -7,22 +7,16 @@ */ /** - * Deductees and their deposit interest, for the two TDS reports. + * Deductees, their deposit interest and the challans, for the two TDS reports. * - * The bank's deduction obligation runs on time deposits, so the seed - * gives each customer a mix of fixed and recurring deposits — and some savings - * accounts, which are outside the section and must stay out of the aggregate. - * - * Everything is generated from a fixed seed so the figures do not move between - * loads, and the challan schedule is deliberately imperfect: one quarter is - * deposited late so the default-interest column has something to show. + * Everything here is what the platform returned. The challans used to be generated from the + * computed deductions - challan numbers, BSR codes and deposit dates made up, one quarter + * deliberately late - so the remittance report reconciled against figures nobody had entered. */ -import { GL_BRANCHES } from '../gl-reports/gl-reports-data'; import { configuredFys, Declaration, - Form15Kind, InterestCredit, TdsAccountInput } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; @@ -32,8 +26,14 @@ export function tdsFys(): string[] { return configuredFys(); } -/** Business date the book is drawn to — Q1 of 2026-27 is closed, Q2 is running. */ -export const TDS_AS_AT = '2026-08-01'; +/** A deposit as the platform reports it: the interest credited and the TDS actually withheld. */ +export interface TdsReportAccount extends TdsAccountInput { + /** TDS the platform actually deducted from this account in the year. */ + deductions: InterestCredit[]; + /** The account's own holder, when the tax is deducted against somebody else (minor, joint). */ + holderClientId?: number; + holderName?: string; +} export interface TdsDeductee { clientId: number; @@ -44,115 +44,35 @@ export interface TdsDeductee { branchCode: string; /** Declarations on record, keyed by financial year. */ declarations: Record; - accounts: TdsAccountInput[]; + accounts: TdsReportAccount[]; } -/** One month's remittance of deducted tax to the Government. */ +/** One challan Head Office entered: tax remitted to the Government. */ export interface TdsChallan { + id: number; fy: string; - /** Quarter the deduction belongs to. */ - quarter: 1 | 2 | 3 | 4; /** Month the tax was deducted in, e.g. '2026-06'. */ month: string; amount: number; - /** Statutory date by which it had to be deposited. */ - dueDate: string; - /** When it actually reached the Government — null while still pending. */ - depositedOn: string | null; - challanNo: string | null; - bsrCode: string | null; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFrom: string; + remarks: string | null; + reversed: boolean; } -/** Deterministic linear congruential generator — same figures on every load. */ -function lcg(seed: number): () => number { - let s = seed >>> 0; - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0; - return s / 4294967296; - }; -} - -function pick(rand: () => number, list: T[]): T { - return list[Math.floor(rand() * list.length)]; -} - -const FIRST_NAMES = [ - 'Ramesh', - 'Sunita', - 'Vitthal', - 'Kamala', - 'Ganpat', - 'Shobha', - 'Dnyaneshwar', - 'Mangala', - 'Bhaskar', - 'Anita', - 'Tukaram', - 'Sarika', - 'Nivrutti', - 'Rekha', - 'Popat', - 'Chhaya', - 'Ashok', - 'Vaishali', - 'Sitaram', - 'Jayshree', - 'Madhukar', - 'Pushpa', - 'Eknath', - 'Sulochana', - 'Hanumant', - 'Kalpana' -]; - -const SURNAMES = [ - 'Jadhav', - 'Pawar', - 'Shinde', - 'Gaikwad', - 'More', - 'Kadam', - 'Bhosale', - 'Patil', - 'Sawant', - 'Chavan', - 'Salunkhe', - 'Mane', - 'Deshmukh', - 'Thorat', - 'Nikam', - 'Wagh' -]; - -/** - * Quarter-end dates for a financial year. Interest is credited on these, which - * is what makes the deduction fall due. - */ -function quarterEnds(fy: string): string[] { - const y = Number(fy.slice(0, 4)); - return [ - `${y}-06-30`, - `${y}-09-30`, - `${y}-12-31`, - `${y + 1}-03-31` - ]; -} - -/** A well-formed individual PAN — fourth character 'P'. */ -function makePan(rand: () => number, surname: string): string { - const letter = () => String.fromCharCode(65 + Math.floor(rand() * 26)); - const digits = `${1000 + Math.floor(rand() * 9000)}`; - return `${letter()}${letter()}${letter()}P${surname[0].toUpperCase()}${digits}${letter()}`; +/** Tax the platform withheld in one month. */ +export interface TdsDeductedMonth { + month: string; + amount: number; + postings: number; } /** * The deductees the reports run on, loaded from the platform. * - * This used to be sixty-four customers generated from a fixed seed — names, PANs, deposits and all. - * The computation over them is the real section 194A calculation, so the figures looked like a - * filing and were not one: not a rupee of it came from the bank's own books. - * - * It is set once by whoever loads the screen and read by the pure computation below, which is why + * It is set once by whoever loads the screen and read by the pure computation, which is why * this is a module-level holder rather than a service the compute would have to inject. */ let liveDeductees: TdsDeductee[] = []; @@ -188,63 +108,3 @@ export function depositDueDate(month: string): string { const nextM = m === 12 ? 1 : m + 1; return `${nextY}-${`${nextM}`.padStart(2, '0')}-07`; } - -let challanCache: Record | null = null; - -/** - * The remittance schedule, derived from what the engine says was deducted. - * - * Deductions land at each quarter end, so there is one challan per quarter. The - * Q2 challan of the prior year is deliberately deposited a month and a bit late - * so the late-deposit interest column is exercised, and the current year's latest - * quarter is left pending, which is the ordinary state of a live book. - */ -export function tdsChallans(deductedByQuarter: Record>): Record { - if (challanCache) { - return challanCache; - } - const out: Record = {}; - - for (const fy of Object.keys(deductedByQuarter)) { - const y = Number(fy.slice(0, 4)); - const months: Record = { - 1: `${y}-06`, - 2: `${y}-09`, - 3: `${y}-12`, - 4: `${y + 1}-03` - }; - const rows: TdsChallan[] = []; - - for (const q of [ - 1, - 2, - 3, - 4 - ] as (1 | 2 | 3 | 4)[]) { - const amount = Math.round(deductedByQuarter[fy]?.[q] ?? 0); - if (amount <= 0) { - continue; - } - const month = months[q]; - const dueDate = depositDueDate(month); - // Anything falling after the business date has not been deposited yet. - const pending = dueDate > TDS_AS_AT; - const late = fy === '2025-26' && q === 2; - - rows.push({ - fy, - quarter: q, - month, - amount, - dueDate, - depositedOn: pending ? null : late ? `${y}-11-14` : dueDate, - challanNo: pending ? null : `${y}${q}${`${amount % 10000}`.padStart(4, '0')}`, - bsrCode: pending ? null : '0510308' - }); - } - out[fy] = rows; - } - - challanCache = out; - return out; -} diff --git a/src/app/tds-reports/tds-reports.compute.spec.ts b/src/app/tds-reports/tds-reports.compute.spec.ts index f3b16c811..ee0aea6e5 100644 --- a/src/app/tds-reports/tds-reports.compute.spec.ts +++ b/src/app/tds-reports/tds-reports.compute.spec.ts @@ -6,13 +6,10 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ -import { isTdsApplicable, tdsDeducteeOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; import { installTdsConfigFixture } from '../letter-mgmt/certificates/form-15gh/tds-config.fixture'; -import { TDS_AS_AT, depositDueDate, tdsDeductees, tdsFys } from './tds-reports-data'; +import { TdsChallan, TdsDeductee, depositDueDate, setTdsDeductees } from './tds-reports-data'; import { - NEAR_THRESHOLD_BAND, TdsReportFilter, - deductedByQuarter, tds26qSummary, tdsInterestRows, tdsInterestRowsForFy, @@ -22,12 +19,81 @@ import { installTdsConfigFixture(); -/** The complete year in the seed — the one a return would be filed for. */ -const CLOSED_FY = '2025-26'; +/** + * A small book with every case the reports must handle once, so each figure below can be worked + * out by hand. The reports used to run on sixty-four generated customers; the tests asserted + * shapes ("some row crossed") because nobody could know the numbers. + */ +const FY = '2025-26'; +const Q = ['2025-06-30', '2025-09-30', '2025-12-31', '2026-03-31']; + +const fd = (id: number, amounts: number[], extra: object = {}) => ({ + id, + accountNo: `FD${id}`, + depositTypeCode: 'depositAccountType.fixedDeposit', + credits: amounts.map((amount, i) => ({ date: Q[i], amount, accountNo: `FD${id}` })), + deductions: [] as { date: string; amount: number }[], + ...extra +}); + +const sb = (id: number, amount: number) => ({ + id, + accountNo: `SB${id}`, + depositTypeCode: 'depositAccountType.savingsDeposit', + credits: [{ date: '2025-09-30', amount, accountNo: `SB${id}` }], + deductions: [] as { date: string; amount: number }[] +}); + +function person( + clientId: number, + name: string, + dob: string, + pan: string, + branch: string, + accounts: any[], + declarations = {} +): TdsDeductee { + return { + clientId, + customerId: `C${clientId}`, + name, + pan, + dob, + branchCode: branch, + declarations, + accounts: accounts.map((a) => ({ clientId, ...a })) + }; +} + +const BOOK: TdsDeductee[] = [ + // Over the limit in Q4, plus a savings account that must stay outside the figure. + person(1, 'Ramesh', '1980-05-01', 'ABCPR1234K', 'Head Office', [ + { ...fd(11, [15_000, 15_000, 15_000, 15_000]), deductions: [{ date: Q[3], amount: 6_000 }] }, + sb(12, 1_200) + ]), + // Senior citizen, no PAN: higher threshold, higher rate - and the platform deducted too little. + person(2, 'Sunita', '1955-01-01', '', 'bhopal', [ + { ...fd(21, [30_000, 30_000, 30_000, 30_000]), deductions: [{ date: Q[3], amount: 20_000 }] } + ]), + // ₹6,000 short of the limit: the customer to ask for a declaration. + person(3, 'Vitthal', '1985-03-03', 'ABCPV5678L', 'bhopal', [fd(31, [22_000, 22_000])]), + // Declaration accepted mid-year: interest before it stays liable, interest after it is paid gross. + person(4, 'Kamala', '1979-07-07', 'ABCPK9012M', 'Head Office', [fd(41, [20_000, 20_000, 20_000, 20_000])], { + [FY]: { kind: '15G', acceptedOn: '2025-10-15', fy: FY, uin: 'UIN0001' } + }), + // A minor's deposit, already listed under the guardian by the platform. + person(5, 'Ganpat', '1975-02-02', 'ABCPG3456N', 'Head Office', [ + { ...fd(51, [5_000]), holderClientId: 99, holderName: 'Baby Ganpat' } + ]), + // Savings only, no PAN: never deducted, so never counted as a no-PAN problem. + person(6, 'Shobha', '1990-09-09', '', 'Head Office', [sb(61, 900)]) +]; + +setTdsDeductees(BOOK); function filter(overrides: Partial = {}): TdsReportFilter { return { - fy: CLOSED_FY, + fy: FY, branchCode: '', quarter: 0, band: '', @@ -38,115 +104,94 @@ function filter(overrides: Partial = {}): TdsReportFilter { }; } -describe('the seed has every case the reports need to show', () => { - it('produces deductees on both sides of the threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - expect(rows.length).toBeGreaterThan(20); - expect(rows.some((r) => r.crossed)).toBe(true); - expect(rows.some((r) => !r.crossed)).toBe(true); - expect(rows.some((r) => r.nearThreshold)).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => !r.panOnRecord)).toBe(true); - expect(rows.some((r) => r.hasDeclaration)).toBe(true); +const row = (name: string) => tdsInterestRowsForFy(FY).find((r) => r.deductee.name === name)!; + +describe('which accounts are tracked', () => { + it('counts the FD and leaves the savings account out, saying why', () => { + const r = row('Ramesh'); + expect(r.tdsApplicableInterest).toBe(60_000); + expect(r.exemptInterest).toBe(1_200); + expect(r.trackedAccounts).toBe(1); + const savings = r.accounts.find((a) => a.type === 'SB')!; + expect(savings.tracked).toBe(false); + expect(savings.reason).toContain('Savings'); + expect(r.accounts[0].type).toBe('FD'); }); - it('gives senior citizens the higher threshold', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } + it('shows whose account a deposit is when the tax falls on the guardian', () => { + const line = row('Ganpat').accounts[0]; + expect(line.tracked).toBe(true); + expect(line.holderName).toBe('Baby Ganpat'); }); - it('deducts at the higher rate wherever PAN is missing', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - expect(row.rateApplied).toBe(row.panOnRecord ? 0.1 : 0.2); - } + it('marks a savings-only customer as not tracked', () => { + expect(row('Shobha').trackedAccounts).toBe(0); }); }); -describe('the reports follow the Act that governed each year', () => { - it('shows 15G / 15H for FY 2025-26 and Form 121 for FY 2026-27', () => { - const old = tdsInterestRowsForFy('2025-26'); - expect(old.some((r) => r.form === '15G')).toBe(true); - expect(old.some((r) => r.form === '15H')).toBe(true); - expect(old.some((r) => r.form === '121')).toBe(false); - - // The two forms merged on 01 April 2026. - const current = tdsInterestRowsForFy('2026-27'); - expect(current.every((r) => r.form === '121')).toBe(true); +describe('the threshold and the rate', () => { + it('deducts on the whole year once the general limit is crossed', () => { + const r = row('Ramesh'); + expect(r.threshold).toBe(50_000); + expect(r.crossedInQuarter).toBe(4); + expect(r.schedule.totalTds).toBe(6_000); }); - it('keeps the age-based threshold band in both years', () => { - for (const fy of tdsFys()) { - const rows = tdsInterestRowsForFy(fy); - expect(rows.some((r) => r.band === 'SENIOR')).toBe(true); - expect(rows.some((r) => r.band === 'GENERAL')).toBe(true); - for (const row of rows) { - expect(row.threshold).toBe(row.band === 'SENIOR' ? 100_000 : 50_000); - } - } + it('gives a senior the higher limit, and a missing PAN the higher rate', () => { + const r = row('Sunita'); + expect(r.band).toBe('SENIOR'); + expect(r.threshold).toBe(100_000); + expect(r.rateApplied).toBe(0.2); + expect(r.schedule.totalTds).toBe(24_000); + }); + + it('flags a customer just under the limit', () => { + const r = row('Vitthal'); + expect(r.crossed).toBe(false); + expect(r.nearThreshold).toBe(true); + expect(r.headroom).toBe(6_000); + expect(r.schedule.totalTds).toBe(0); }); }); -describe('savings interest stays out of the 194A aggregate', () => { - it('reports it separately and never inside the applicable interest', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const withSavings = rows.filter((r) => r.exemptInterest > 0); - expect(withSavings.length).toBeGreaterThan(0); +describe('a mid-year declaration', () => { + it('taxes only the interest credited before it was accepted', () => { + const r = row('Kamala'); + expect(r.hasDeclaration).toBe(true); + // ₹40,000 credited before 15 Oct is liable once the year's ₹80,000 crosses the limit. + expect(r.schedule.totalTds).toBe(4_000); + expect(r.tdsSaved).toBe(4_000); + expect(r.paidWithoutDeduction).toBe(40_000); + }); +}); - for (const row of withSavings) { - // The applicable figure must equal only the time-deposit interest where - // this customer is the deductee. - const expected = row.deductee.accounts - .filter((a) => isTdsApplicable(a) && tdsDeducteeOf(a) === row.deductee.clientId) - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.tdsApplicableInterest).toBe(expected); - } +describe('due against actually deducted', () => { + it('agrees where the platform deducted the right amount', () => { + expect(row('Ramesh').actualTds).toBe(6_000); }); - it('leaves the savings totals out of the summary base', () => { + it('shows the gap where it did not', () => { + const r = row('Sunita'); + expect(r.actualTds).toBe(20_000); + expect(r.schedule.totalTds - r.actualTds).toBe(4_000); + }); + + it('totals both in the summary', () => { const s = tdsInterestSummary(filter()); - expect(s.exemptInterest).toBeGreaterThan(0); - expect(s.applicableInterest).toBe(tdsInterestRows(filter()).reduce((acc, r) => acc + r.tdsApplicableInterest, 0)); + expect(s.totalTds).toBe(6_000 + 24_000 + 4_000); + expect(s.actualTds).toBe(26_000); }); }); -describe('joint deposits are attributed to the first-named holder only', () => { - it('excludes second-holder interest from the aggregate and shows it apart', () => { - const rows = tdsInterestRowsForFy(CLOSED_FY); - const secondHolders = rows.filter((r) => r.jointHeldInterest > 0); - expect(secondHolders.length).toBeGreaterThan(0); - - for (const row of secondHolders) { - const jointAccounts = row.deductee.accounts.filter( - (a) => isTdsApplicable(a) && tdsDeducteeOf(a) !== row.deductee.clientId - ); - expect(jointAccounts.length).toBeGreaterThan(0); - // None of that interest may appear in the figure the threshold is tested - // against, or one deposit would be taxed against two people. - const jointTotal = jointAccounts - .flatMap((a) => a.credits) - .filter((c) => c.date >= '2025-04-01' && c.date <= '2026-03-31') - .reduce((s, c) => s + c.amount, 0); - expect(row.jointHeldInterest).toBe(jointTotal); - expect(row.tdsApplicableInterest).toBeLessThan(row.tdsApplicableInterest + jointTotal); - } - }); -}); - -describe('quarter figures add up to the year', () => { - it('per row', () => { - for (const row of tdsInterestRowsForFy(CLOSED_FY)) { - const quarterInterest = row.quarters[1] + row.quarters[2] + row.quarters[3] + row.quarters[4]; - expect(quarterInterest).toBe(row.tdsApplicableInterest); - expect(row.schedule.lines.reduce((s, l) => s + l.tds, 0)).toBe(row.schedule.totalTds); - } +describe('the summary', () => { + it('counts a missing PAN only where a deposit is being tracked', () => { + const s = tdsInterestSummary(filter()); + expect(s.noPan).toBe(1); + expect(s.trackedCustomers).toBe(5); + expect(s.exemptInterest).toBe(2_100); }); - it('and across the summary', () => { + it('adds the quarters up to the year', () => { const s = tdsInterestSummary(filter()); expect(s.byQuarter.reduce((acc, q) => acc + q.interest, 0)).toBe(s.applicableInterest); expect(s.byQuarter.reduce((acc, q) => acc + q.tds, 0)).toBe(s.totalTds); @@ -154,151 +199,116 @@ describe('quarter figures add up to the year', () => { }); describe('filters', () => { - it("narrows to a branch without changing anyone's figures", () => { - const all = tdsInterestRows(filter()); - const branch = all[0].branchCode; - const scoped = tdsInterestRows(filter({ branchCode: branch })); - expect(scoped.length).toBeGreaterThan(0); - expect(scoped.every((r) => r.branchCode === branch)).toBe(true); - // A branch view must not re-aggregate — the threshold is computed bank-wide. - const sample = scoped[0]; - const sameRow = all.find((r) => r.deductee.customerId === sample.deductee.customerId); - expect(sample.tdsApplicableInterest).toBe(sameRow?.tdsApplicableInterest); + it('narrows to a branch by office name, whatever its case, without re-aggregating', () => { + const scoped = tdsInterestRows(filter({ branchCode: 'BHOPAL' })); + expect(scoped.map((r) => r.deductee.name).sort()).toEqual(['Sunita', 'Vitthal']); + expect(scoped.find((r) => r.deductee.name === 'Sunita')!.schedule.totalTds).toBe(24_000); }); - it('finds the customers worth chasing a declaration from', () => { - const near = tdsInterestRows(filter({ nearThresholdOnly: true })); - expect(near.length).toBeGreaterThan(0); - for (const row of near) { - expect(row.crossed).toBe(false); - expect(row.headroom).toBeGreaterThan(0); - expect(row.headroom).toBeLessThanOrEqual(NEAR_THRESHOLD_BAND); - } + it('finds the customers near the limit and those over it', () => { + expect(tdsInterestRows(filter({ nearThresholdOnly: true })).map((r) => r.deductee.name)).toEqual(['Vitthal']); + expect( + tdsInterestRows(filter({ crossedOnly: true })) + .map((r) => r.deductee.name) + .sort() + ).toEqual(['Kamala', 'Ramesh', 'Sunita']); }); - it('narrows to customers who crossed', () => { - const crossed = tdsInterestRows(filter({ crossedOnly: true })); - expect(crossed.length).toBeGreaterThan(0); - expect(crossed.every((r) => r.crossed && r.tdsApplicableInterest > r.threshold)).toBe(true); - }); - - it('searches on name, CIF and PAN', () => { - const target = tdsInterestRowsForFy(CLOSED_FY).find((r) => r.panOnRecord)!; - expect(tdsInterestRows(filter({ search: target.deductee.customerId })).length).toBe(1); - expect(tdsInterestRows(filter({ search: target.deductee.pan })).length).toBeGreaterThanOrEqual(1); + it('searches on name, customer ID and PAN', () => { + expect(tdsInterestRows(filter({ search: 'C3' })).length).toBe(1); + expect(tdsInterestRows(filter({ search: 'abcpk9012m' }))[0].deductee.name).toBe('Kamala'); }); }); -describe('declarations', () => { - it('save tax and record the interest paid gross', () => { - const declared = tdsInterestRowsForFy(CLOSED_FY).filter((r) => r.hasDeclaration); - expect(declared.length).toBeGreaterThan(0); - expect(declared.some((r) => r.tdsSaved > 0)).toBe(true); - // Interest paid without deduction is a mandatory 26Q disclosure, so it must - // be carried even though no tax came off it. - expect(declared.some((r) => r.paidWithoutDeduction > 0)).toBe(true); - for (const row of declared) { - expect(row.declarationUin).toBeTruthy(); - } +describe('remittance', () => { + const challan = (over: Partial): TdsChallan => ({ + id: 1, + fy: FY, + month: '2026-03', + amount: 0, + challanNo: '00001', + bsrCode: '0510308', + depositedOn: '2026-04-20', + paidFrom: '', + remarks: null, + reversed: false, + ...over }); - it('leaves tax deducted before a mid-year declaration in place', () => { - // Some declarations in the seed were accepted in October. Tax already taken - // in Q1/Q2 cannot be reversed, so those rows still carry a deduction. - const midYear = tdsInterestRowsForFy(CLOSED_FY).filter( - (r) => r.hasDeclaration && r.declarationAcceptedOn && r.declarationAcceptedOn.slice(5, 7) === '10' - ); - expect(midYear.length).toBeGreaterThan(0); - expect(midYear.some((r) => r.schedule.totalTds > 0)).toBe(true); - }); -}); - -describe('remittance schedule', () => { - it('deposits by the seventh of the following month, and 30 April for March', () => { + it('pays by the seventh of the following month, and 30 April for March', () => { expect(depositDueDate('2025-06')).toBe('2025-07-07'); expect(depositDueDate('2025-12')).toBe('2026-01-07'); expect(depositDueDate('2026-03')).toBe('2026-04-30'); }); - it('remits exactly what the engine says was deducted', () => { - const deducted = deductedByQuarter(CLOSED_FY); - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - for (const row of rows) { - expect(row.amount).toBe(Math.round(deducted[row.quarter])); - } + it('marks a month paid in full and on time', () => { + const [r] = tdsRemittanceRows(FY, [{ month: '2026-03', amount: 26_000, postings: 2 }], [challan({ amount: 26_000 })], '2026-06-01'); + expect(r.quarter).toBe(4); + expect(r.outstanding).toBe(0); + expect(r.late).toBe(false); + expect(r.overdue).toBe(false); + expect(r.defaultInterest).toBe(0); }); - it('charges section 201(1A) interest on a late deposit and nothing on a timely one', () => { - const rows = tdsRemittanceRows(CLOSED_FY, TDS_AS_AT); - const late = rows.filter((r) => r.late); - expect(late.length).toBeGreaterThan(0); - for (const row of late) { - expect(row.delayMonths).toBeGreaterThan(0); - expect(row.defaultInterest).toBe(Math.round(row.amount * 0.015 * row.delayMonths)); - } - for (const row of rows.filter((r) => !r.late && !r.overdue)) { - expect(row.defaultInterest).toBe(0); - } + it('charges interest on a late challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, depositedOn: '2026-05-02' })], + '2026-06-01' + ); + expect(r.late).toBe(true); + expect(r.delayMonths).toBeGreaterThan(0); + expect(r.defaultInterest).toBeGreaterThan(0); }); -}); -describe('the 26Q reconciliation control', () => { - it.each(tdsFys())('balances for FY %s', (fy) => { - const s = tds26qSummary(fy, TDS_AS_AT); - // Deducted must equal deposited plus what is still within its due date. - // Anything else means tax has gone missing between the ledger and the - // Government, and the return must not be filed until it is found. + it('flags an unpaid month past its due date, and not one still within it', () => { + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 7_000, postings: 1 } + ], + [], + '2026-04-10' + ); + const dec = rows.find((r) => r.month === '2025-12')!; + const mar = rows.find((r) => r.month === '2026-03')!; + expect(dec.overdue).toBe(true); + expect(dec.defaultInterest).toBeGreaterThan(0); + expect(mar.overdue).toBe(false); + expect(mar.outstanding).toBe(7_000); + }); + + it('ignores a reversed challan', () => { + const [r] = tdsRemittanceRows( + FY, + [{ month: '2026-03', amount: 26_000, postings: 2 }], + [challan({ amount: 26_000, reversed: true })], + '2026-04-10' + ); + expect(r.deposited).toBe(0); + expect(r.outstanding).toBe(26_000); + }); + + it('reconciles deducted against paid plus still-within-due', () => { + const asAt = '2026-04-10'; + const rows = tdsRemittanceRows( + FY, + [ + { month: '2025-12', amount: 5_000, postings: 1 }, + { month: '2026-03', amount: 21_000, postings: 1 } + ], + [challan({ month: '2025-12', amount: 5_000, depositedOn: '2026-01-05' })], + asAt + ); + const s = tds26qSummary(FY, rows, asAt); + expect(s.totalDeducted).toBe(26_000); + expect(s.totalDeposited).toBe(5_000); + expect(s.pendingDeposit).toBe(21_000); expect(s.difference).toBe(0); - expect(s.totalDeducted).toBe(s.totalDeposited + s.pendingDeposit); - }); - - it('splits the tax between the standard and the no-PAN rate without loss', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(s.totalDeducted); - expect(s.tdsAtHigherRate).toBeGreaterThan(0); - }); - - it('splits interest into the deducted and the paid-gross halves', () => { - const s = tds26qSummary(CLOSED_FY, TDS_AS_AT); - expect(s.interestWithDeduction + s.interestWithoutDeduction).toBe(s.totalInterestPaid); - // The gross-paid half is the Form 121 disclosure and must not be zero here, - // or the report would never exercise that line. - expect(s.interestWithoutDeduction).toBeGreaterThan(0); - }); - - it('agrees with the interest report on the same year', () => { - const summary = tdsInterestSummary(filter()); - expect(tds26qSummary(CLOSED_FY, TDS_AS_AT).totalDeducted).toBe(summary.totalTds); - }); -}); - -describe('the part-run current year', () => { - it('has postings only up to the business date', () => { - for (const deductee of tdsDeductees()) { - for (const account of deductee.accounts) { - for (const credit of account.credits) { - expect(credit.date <= TDS_AS_AT).toBe(true); - } - } - } - }); - - it('shows the closed quarter as settled and future obligations as not yet due', () => { - const rows = tdsRemittanceRows('2026-27', TDS_AS_AT); - - // Q1 (Apr–Jun) has closed, so its tax was deducted and is due by 07 Jul. - expect(rows.some((r) => r.quarter === 1)).toBe(true); - - // Q3 cannot appear: no interest has posted there and nothing settles there. - expect(rows.some((r) => r.quarter === 3)).toBe(false); - - // Q4 *can* appear even in a part-run year — deposits on annual interest - // posting settle once, at year end — but that is a future obligation, so it - // must read as pending and must never be flagged overdue. - for (const row of rows.filter((r) => r.quarter === 4)) { - expect(row.depositedOn).toBeNull(); - expect(row.overdue).toBe(false); - expect(row.defaultInterest).toBe(0); - } + // The year's figures come from the same book as the interest report. + expect(s.calculatedTds).toBe(34_000); + expect(s.tdsAtStandardRate + s.tdsAtHigherRate).toBe(26_000); }); }); diff --git a/src/app/tds-reports/tds-reports.compute.ts b/src/app/tds-reports/tds-reports.compute.ts index 7d1af6c6e..de0e0e568 100644 --- a/src/app/tds-reports/tds-reports.compute.ts +++ b/src/app/tds-reports/tds-reports.compute.ts @@ -17,7 +17,6 @@ import { DeclarationForm, - FY_TAX_CONFIG, Quarter, TdsMode, TdsSchedule, @@ -36,7 +35,15 @@ import { tdsDeducteeOf, tdsModeFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TdsChallan, TdsDeductee, tdsChallans, tdsDeductees, tdsDeducteesVersion } from './tds-reports-data'; +import { + TdsChallan, + TdsDeductedMonth, + TdsDeductee, + TdsReportAccount, + depositDueDate, + tdsDeductees, + tdsDeducteesVersion +} from './tds-reports-data'; // ─────────────────────────────── Filtering ─────────────────────────────── @@ -73,6 +80,10 @@ export interface TdsAccountLine { /** How many interest postings made up that figure. */ postings: number; lastPostedOn: string | null; + /** TDS the platform actually withheld from this account in the year. */ + tdsDeducted: number; + /** Set when the tax is deducted against someone other than the account's own holder. */ + holderName: string | null; } /** How close to the trigger counts as "worth chasing a declaration for". */ @@ -124,6 +135,11 @@ export interface TdsInterestRow { accounts: TdsAccountLine[]; /** How many of them count toward the deduction. */ trackedAccounts: number; + /** + * TDS the platform actually withheld this year. `schedule.totalTds` is what this report works + * out is due; the two should agree, and a difference is the first thing to investigate. + */ + actualTds: number; } function emptyQuarters(): Record { @@ -192,8 +208,9 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { reason = 'Joint deposit — counted under the first-named holder'; } else { tracked = true; - reason = 'Counted toward the TDS limit'; + reason = 'Its interest received in savings counts toward the TDS limit'; } + const report = account as TdsReportAccount; accountLines.push({ accountNo: account.accountNo || '', type, @@ -201,7 +218,10 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { reason, interest: inYear, postings: yearCredits.length, - lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null) + lastPostedOn: yearCredits.reduce((last, c) => (!last || c.date > last ? c.date : last), null), + tdsDeducted: (report.deductions || []).filter((c) => fyOf(c.date) === fy).reduce((s, c) => s + c.amount, 0), + holderName: + report.holderClientId && report.holderClientId !== deductee.clientId ? (report.holderName ?? null) : null }); } // Tracked accounts first, so the deposits that drive the figure lead the list. @@ -236,7 +256,8 @@ export function tdsInterestRowsForFy(fy: string): TdsInterestRow[] { panOnRecord: isWellFormedPan(deductee.pan), rateApplied: schedule.rateApplied, accounts: accountLines, - trackedAccounts: accountLines.filter((a) => a.tracked).length + trackedAccounts: accountLines.filter((a) => a.tracked).length, + actualTds: accountLines.reduce((s, a) => s + a.tdsDeducted, 0) }); } @@ -313,6 +334,8 @@ export interface TdsInterestSummary { /** Savings interest, outside the section. */ exemptInterest: number; totalTds: number; + /** TDS the platform actually withheld — set against `totalTds`, which is what is due. */ + actualTds: number; crossed: number; nearThreshold: number; declarations: number; @@ -342,6 +365,7 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary applicableInterest: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), exemptInterest: rows.reduce((s, r) => s + r.exemptInterest, 0), totalTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), + actualTds: rows.reduce((s, r) => s + r.actualTds, 0), crossed: rows.filter((r) => r.crossed).length, nearThreshold: rows.filter((r) => r.nearThreshold).length, declarations: rows.filter((r) => r.hasDeclaration).length, @@ -357,14 +381,34 @@ export function tdsInterestSummary(filter: TdsReportFilter): TdsInterestSummary // ─────────────────────── Report 2: remittance ─────────────────────── -export interface TdsRemittanceRow extends TdsChallan { - /** Statutory due date has passed with nothing deposited. */ +/** + * One deduction month: what the platform withheld, and what Head Office remitted against it. + * + * Driven by the withhold-tax postings and the challans entered, never by this report's own + * computation — a return reconciles what was deducted, not what should have been. + */ +export interface TdsRemittanceRow { + fy: string; + quarter: Quarter; + /** Month the tax was deducted in, e.g. '2026-06'. */ + month: string; + deducted: number; + postings: number; + /** Live challans entered against this month. */ + challans: TdsChallan[]; + deposited: number; + lastDepositedOn: string | null; + dueDate: string; + /** Deducted but not yet remitted. */ + outstanding: number; + /** Remitted beyond what was deducted — a challan entered against the wrong month, usually. */ + excess: number; + /** Something is still outstanding and the due date has passed. */ overdue: boolean; - /** Deposited, but after the due date. */ + /** A challan reached the Government after the due date. */ late: boolean; - /** Months or part months of delay. */ delayMonths: number; - /** Interest charged for depositing late. */ + /** Interest the bank owes for depositing late. */ defaultInterest: number; /** Return the quarter's deduction is reported in. */ returnDueDate: string; @@ -377,41 +421,78 @@ const RETURN_DUE: Record = { 4: '31 May' }; -/** Tax deducted per quarter, from the engine, for one year. */ -export function deductedByQuarter(fy: string): Record { - const out: Record = { 1: 0, 2: 0, 3: 0, 4: 0 }; - for (const row of tdsInterestRowsForFy(fy)) { - for (const line of row.schedule.lines) { - out[line.quarter] += line.tds; - } +export function quarterOfMonth(month: string): Quarter { + const m = Number(month.slice(5, 7)); + if (m >= 4 && m <= 6) { + return 1; } - return out; + if (m >= 7 && m <= 9) { + return 2; + } + if (m >= 10) { + return 3; + } + return 4; } -export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] { - const deducted: Record> = {}; - for (const year of Object.keys(FY_TAX_CONFIG)) { - deducted[year] = deductedByQuarter(year); - } - const challans = tdsChallans(deducted)[fy] ?? []; +function round2(n: number): number { + return Math.round(n * 100) / 100; +} - return challans.map((c) => { - const overdue = !c.depositedOn && c.dueDate < asAt; - const late = !!c.depositedOn && c.depositedOn > c.dueDate; - // While a payment is overdue the clock runs to today; once paid it stops on - // the payment date. - const settledOn = c.depositedOn ?? asAt; - const delayMonths = late || overdue ? monthsOrPartBetween(c.dueDate, settledOn) : 0; - const defaultInterest = - late || overdue ? lateDeductionInterest(c.amount, c.dueDate, settledOn, 'DEPOSIT', fy).interest : 0; +export function tdsRemittanceRows( + fy: string, + deducted: TdsDeductedMonth[], + challans: TdsChallan[], + asAt: string +): TdsRemittanceRow[] { + const live = challans.filter((c) => !c.reversed && c.fy === fy); + const months = [...new Set([...deducted.map((d) => d.month), ...live.map((c) => c.month)])].sort(); + + return months.map((month) => { + const d = deducted.find((x) => x.month === month); + const amount = d?.amount ?? 0; + const forMonth = live.filter((c) => c.month === month); + const deposited = round2(forMonth.reduce((s, c) => s + c.amount, 0)); + const lastDepositedOn = forMonth.reduce( + (last, c) => (!last || c.depositedOn > last ? c.depositedOn : last), + null + ); + const dueDate = depositDueDate(month); + const outstanding = Math.max(0, round2(amount - deposited)); + const excess = Math.max(0, round2(deposited - amount)); + const overdue = outstanding > 0 && dueDate < asAt; + const lateChallans = forMonth.filter((c) => c.depositedOn > dueDate); + const late = lateChallans.length > 0; + + // Interest runs per challan from the due date to the day it was paid, and on whatever is still + // outstanding from the due date to today. + let defaultInterest = lateChallans.reduce( + (s, c) => s + lateDeductionInterest(c.amount, dueDate, c.depositedOn, 'DEPOSIT', fy).interest, + 0 + ); + if (overdue) { + defaultInterest += lateDeductionInterest(outstanding, dueDate, asAt, 'DEPOSIT', fy).interest; + } + const settledOn = overdue ? asAt : lastDepositedOn; + const delayMonths = (late || overdue) && settledOn ? monthsOrPartBetween(dueDate, settledOn) : 0; return { - ...c, + fy, + quarter: quarterOfMonth(month), + month, + deducted: amount, + postings: d?.postings ?? 0, + challans: forMonth, + deposited, + lastDepositedOn, + dueDate, + outstanding, + excess, overdue, late, delayMonths, defaultInterest, - returnDueDate: RETURN_DUE[c.quarter] + returnDueDate: RETURN_DUE[quarterOfMonth(month)] }; }); } @@ -419,9 +500,9 @@ export function tdsRemittanceRows(fy: string, asAt: string): TdsRemittanceRow[] /** * The year-end position — what the quarterly TDS return has to reconcile to. * - * `difference` is the control figure: tax the engine says was deducted, less what - * actually reached the Government. Anything other than zero, once the pending - * challans are settled, means something is wrong and must be found before filing. + * `difference` is the control figure: tax actually deducted, less what reached the Government, + * less what is still within its due date. Anything other than zero means a month is overdue or + * a challan sits against the wrong month. */ export interface Tds26qSummary { fy: string; @@ -432,9 +513,13 @@ export interface Tds26qSummary { interestWithoutDeduction: number; tdsAtStandardRate: number; tdsAtHigherRate: number; + /** What this report works out was due. */ + calculatedTds: number; + /** What the platform actually withheld from accounts. */ totalDeducted: number; totalDeposited: number; pendingDeposit: number; + overdueAmount: number; /** Deducted less deposited less still-pending. Must be zero. */ difference: number; defaultInterest: number; @@ -442,29 +527,33 @@ export interface Tds26qSummary { overdueChallans: number; } -export function tds26qSummary(fy: string, asAt: string): Tds26qSummary { +export function tds26qSummary(fy: string, remittance: TdsRemittanceRow[], asAt: string): Tds26qSummary { const rows = tdsInterestRowsForFy(fy); - const challans = tdsRemittanceRows(fy, asAt); const standard = rows.filter((r) => r.panOnRecord); const higher = rows.filter((r) => !r.panOnRecord); - const totalDeducted = rows.reduce((s, r) => s + r.schedule.totalTds, 0); - const totalDeposited = challans.filter((c) => c.depositedOn).reduce((s, c) => s + c.amount, 0); - const pendingDeposit = challans.filter((c) => !c.depositedOn).reduce((s, c) => s + c.amount, 0); + const totalDeducted = round2(remittance.reduce((s, r) => s + r.deducted, 0)); + const totalDeposited = round2(remittance.reduce((s, r) => s + r.deposited, 0)); + const pendingDeposit = round2( + remittance.filter((r) => r.dueDate >= asAt).reduce((s, r) => s + r.outstanding, 0) + ); + const overdueAmount = round2(remittance.filter((r) => r.overdue).reduce((s, r) => s + r.outstanding, 0)); return { fy, - deductees: rows.length, + deductees: rows.filter((r) => r.trackedAccounts > 0).length, totalInterestPaid: rows.reduce((s, r) => s + r.tdsApplicableInterest, 0), interestWithDeduction: rows.reduce((s, r) => s + r.schedule.liableInterest, 0), interestWithoutDeduction: rows.reduce((s, r) => s + r.paidWithoutDeduction, 0), - tdsAtStandardRate: standard.reduce((s, r) => s + r.schedule.totalTds, 0), - tdsAtHigherRate: higher.reduce((s, r) => s + r.schedule.totalTds, 0), + tdsAtStandardRate: standard.reduce((s, r) => s + r.actualTds, 0), + tdsAtHigherRate: higher.reduce((s, r) => s + r.actualTds, 0), + calculatedTds: rows.reduce((s, r) => s + r.schedule.totalTds, 0), totalDeducted, totalDeposited, pendingDeposit, - difference: Math.round(totalDeducted - totalDeposited - pendingDeposit), - defaultInterest: challans.reduce((s, c) => s + c.defaultInterest, 0), - lateChallans: challans.filter((c) => c.late).length, - overdueChallans: challans.filter((c) => c.overdue).length + overdueAmount, + difference: round2(totalDeducted - totalDeposited - pendingDeposit), + defaultInterest: remittance.reduce((s, r) => s + r.defaultInterest, 0), + lateChallans: remittance.filter((r) => r.late).length, + overdueChallans: remittance.filter((r) => r.overdue).length }; } diff --git a/src/app/tds-reports/tds-reports.service.ts b/src/app/tds-reports/tds-reports.service.ts index 8856cc8b7..89d50c912 100644 --- a/src/app/tds-reports/tds-reports.service.ts +++ b/src/app/tds-reports/tds-reports.service.ts @@ -13,16 +13,52 @@ import { Observable, map } from 'rxjs'; /** Custom Helpers */ import { toIsoDate } from '../core/utils/api-date'; -import { configuredFys } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TdsDeductee } from './tds-reports-data'; +import { Declaration, formKindFor } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; +import { TdsChallan, TdsDeductedMonth, TdsDeductee } from './tds-reports-data'; + +/** A declaration as recorded on the platform. */ +export interface TdsDeclarationRecord { + id: number; + clientId: number; + clientName?: string; + fy: string; + formType: '15G' | '15H' | '121'; + uin: string | null; + acceptedOn: string; + estimatedIncome: number | null; + revoked: boolean; + revokedOn: string | null; + revokeReason: string | null; +} + +export interface NewTdsDeclaration { + clientId: number; + fy: string; + formType: string; + acceptedOn: string; + uin?: string; + estimatedIncome?: number | null; +} + +export interface NewTdsChallan { + fy: string; + month: string; + amount: number; + challanNo: string; + bsrCode: string; + depositedOn: string; + paidFromGlAccountId: number; + remarks?: string; +} /** - * Deposit interest for the TDS reports. + * Deposit interest, declarations and challans for the TDS screens. * - * The platform returns the interest it actually credited — nothing else. A deposit is the - * customer's own money arriving and a fee is a charge, so neither is income and neither is sent. - * Working the figure out here from a balance and a rate would produce a number the passbook does - * not show. + * On an FD or RD the platform returns the interest that reached the customer's savings account and + * the TDS debited there for it — TDS is tracked and taken where the interest is received. A deposit + * is the customer's own money arriving and a fee is a charge, so neither is income and neither is + * sent. Working the figure out here from a balance and a rate would produce a number the passbook + * does not show. */ @Injectable({ providedIn: 'root' }) export class TdsReportsService { @@ -32,57 +68,129 @@ export class TdsReportsService { * @param financialYear the opening calendar year — 2026 means 1 Apr 2026 to 31 Mar 2027. * Omit to let the platform use the year the business date falls in. */ - getDepositInterest(financialYear?: number): Observable { - const params = financialYear ? new HttpParams().set('financialYear', String(financialYear)) : undefined; + getDepositInterest(financialYear?: number, clientId?: number): Observable { + let params = new HttpParams(); + if (financialYear) { + params = params.set('financialYear', String(financialYear)); + } + if (clientId) { + params = params.set('clientId', String(clientId)); + } return this.http .get('/tds/deposit-interest', { params }) .pipe(map((rows) => (rows || []).map((r) => this.toDeductee(r)))); } + getDeclarations(clientId: number): Observable { + return this.http.get('/tds/declarations', { + params: new HttpParams().set('clientId', String(clientId)) + }); + } + + createDeclaration(declaration: NewTdsDeclaration): Observable { + return this.http.post('/tds/declarations', declaration); + } + + revokeDeclaration(id: number, reason: string): Observable { + return this.http.post(`/tds/declarations/${id}/revoke`, { reason }); + } + + /** Tax actually withheld per month, and the business date the books are on. */ + getDeducted(fy: string): Observable<{ asAt: string; months: TdsDeductedMonth[] }> { + return this.http.get('/tds/deducted', { params: new HttpParams().set('fy', fy) }).pipe( + map((res) => ({ + asAt: res?.asAt ?? '', + months: (res?.months || []).map((m: any) => ({ + month: m.month, + amount: Number(m.amount) || 0, + postings: Number(m.postings) || 0 + })) + })) + ); + } + + getChallans(fy: string): Observable { + return this.http + .get('/tds/challans', { params: new HttpParams().set('fy', fy) }) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + getChallanTemplate(): Observable<{ paidFromOptions: { id: number; glCode: string; name: string }[] }> { + return this.http.get('/tds/challans/template'); + } + + createChallan(challan: NewTdsChallan): Observable { + return this.http.post('/tds/challans', challan).pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + reverseChallan(id: number): Observable { + return this.http + .post(`/tds/challans/${id}/reverse`, {}) + .pipe(map((rows) => (rows || []).map((r) => this.toChallan(r)))); + } + + private toChallan(r: any): TdsChallan { + return { + id: r.id, + fy: r.fy, + month: r.month, + amount: Number(r.amount) || 0, + challanNo: r.challanNo, + bsrCode: r.bsrCode, + depositedOn: (r.depositedOn || '').slice(0, 10), + paidFrom: r.paidFrom || '', + remarks: r.remarks ?? null, + reversed: !!r.reversed + }; + } + /** - * Shapes one platform row into what the section 194A computation expects. + * Shapes one platform row into what the TDS computation expects. * * Dates arrive as [y, M, d] arrays and are flattened to ISO here, because the computation - * compares them as strings when deciding which financial year a credit falls in. + * compares them as strings when deciding which financial year a credit falls in. The platform + * has already grouped a minor's deposit under the guardian and a joint deposit under its primary + * holder, so every account here belongs to the deductee it is listed under. */ private toDeductee(row: any): TdsDeductee { + const dob = toIsoDate(row.dateOfBirth); + const credit = (a: any) => (c: any) => ({ + date: toIsoDate(c.date), + amount: Number(c.amount) || 0, + accountNo: c.accountNo || a.accountNo || '' + }); return { clientId: row.clientId, customerId: row.customerId || '', name: row.name || '', pan: row.pan || '', - dob: toIsoDate(row.dateOfBirth), + dob, branchCode: row.branchName || '', - // A 15G/15H on the customer's KYC record covers the year it was accepted for. The platform - // carries the kind but not the acceptance date, so the year's start stands in — a - // declaration is operative for the year it was given, which is what the computation needs. - declarations: this.declarationsOf(row), + declarations: this.declarationsOf(row.declarations || [], dob), accounts: (row.accounts || []).map((a: any) => ({ id: a.id, accountNo: a.accountNo || '', depositTypeCode: a.depositTypeCode || '', - accountStatus: a.accountStatus || undefined, + // The deductee, not the holder: the grouping above already resolved whose PAN it is. clientId: a.clientId ?? row.clientId, - credits: (a.credits || []).map((c: any) => ({ - date: toIsoDate(c.date), - amount: Number(c.amount) || 0, - accountNo: c.accountNo || a.accountNo || '' - })) + holderClientId: a.holderClientId ?? undefined, + holderName: a.holderName ?? undefined, + credits: (a.credits || []).map(credit(a)), + deductions: (a.deductions || []).map(credit(a)) })) }; } - private declarationsOf(row: any): Record { - const kind = (row.form15Type || '').trim().toUpperCase(); - if (kind !== '15G' && kind !== '15H') { - return {}; - } - const out: Record = {}; - // Applied to every configured year. Without an acceptance date on the record the declaration is - // treated as operative from the start of each year rather than mid-year. Keyed '2026-27', the - // form the computation looks it up by - an 'FY 2026-27' key was never found. - for (const fy of configuredFys()) { - out[fy] = { kind, acceptedOn: `${fy.slice(0, 4)}-04-01`, fy }; + /** + * Declarations keyed by year, the form the computation looks them up by ('2026-27'). A Form 121 + * carries no G/H split, so the band follows the customer's age in that year. + */ + private declarationsOf(rows: any[], dob: string): Record { + const out: Record = {}; + for (const d of rows) { + const fy = d.fy; + const kind = d.formType === '15H' ? '15H' : d.formType === '15G' ? '15G' : formKindFor(dob, fy); + out[fy] = { kind, acceptedOn: toIsoDate(d.acceptedOn), fy, uin: d.uin || undefined }; } return out; } diff --git a/src/app/tds-reports/tds-reports.shared.scss b/src/app/tds-reports/tds-reports.shared.scss index 8a35dd430..1fa00d346 100644 --- a/src/app/tds-reports/tds-reports.shared.scss +++ b/src/app/tds-reports/tds-reports.shared.scss @@ -726,3 +726,43 @@ margin-top: 16px; } } + +/* ── Challan form (remittance report) ── */ +.tdsr__form { + padding-bottom: 14px; +} + +.tdsr__filters--flat { + margin: 0; + border: 0; + box-shadow: none; +} + +.tdsr__form-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + padding: 0 16px; +} + +.tdsr__btn--primary { + border-color: var(--tdsr-blue); + background: var(--tdsr-blue); + color: #fff; +} + +.tdsr__btn--sm { + padding: 3px 10px; + font-size: 11.5px; +} + +.tdsr__link { + margin-left: 6px; + padding: 0; + border: 0; + background: none; + color: var(--tdsr-amber); + font: inherit; + text-decoration: underline; + cursor: pointer; +} diff --git a/src/app/tds-reports/tds-statutory.compute.ts b/src/app/tds-reports/tds-statutory.compute.ts index bbb50e748..87f74f0dc 100644 --- a/src/app/tds-reports/tds-statutory.compute.ts +++ b/src/app/tds-reports/tds-statutory.compute.ts @@ -32,7 +32,6 @@ import { quarterEnd, quarterOf } from '../letter-mgmt/certificates/form-15gh/tds-15gh.compute'; -import { TDS_AS_AT } from './tds-reports-data'; import { TdsInterestRow, tdsInterestRowsForFy } from './tds-reports.compute'; /** The bank's own deductor particulars, which head every filing. */ @@ -131,7 +130,7 @@ function monthLabel(key: string): string { * the portal rejects the line, and a silently dropped row is how a bank ends up * reporting fewer declarations than it holds. */ -export function declarationStatement(fy: string, asAt: string = TDS_AS_AT): DeclarationStatement { +export function declarationStatement(fy: string, asAt: string): DeclarationStatement { const year = normaliseFy(fy); const cfg = FY_TAX_CONFIG[year]; if (!cfg) { diff --git a/src/app/web-app.component.ts b/src/app/web-app.component.ts index c27a3984f..5645ad422 100644 --- a/src/app/web-app.component.ts +++ b/src/app/web-app.component.ts @@ -8,7 +8,7 @@ /* eslint-disable @angular-eslint/prefer-inject */ /** Angular Imports */ -import { Component, OnInit, HostListener, HostBinding, OnDestroy } from '@angular/core'; +import { Component, OnInit, HostListener, HostBinding, OnDestroy, inject } from '@angular/core'; import { Router, NavigationEnd, ActivatedRoute } from '@angular/router'; import { Title } from '@angular/platform-browser'; import { MatSnackBar } from '@angular/material/snack-bar'; @@ -30,6 +30,7 @@ import { ThemeStorageService } from './shared/theme-picker/theme-storage.service import { AlertService } from './core/alert/alert.service'; import { AuthenticationService } from './core/authentication/authentication.service'; import { SettingsService } from './settings/settings.service'; +import { SystemService } from './system/system.service'; import { DocumentationLinksService } from 'app/shared/services/documentation-links.service'; import { IdleTimeoutService } from './home/timeout-dialog/idle-timeout.service'; import { SessionTimeoutDialogComponent } from './home/timeout-dialog/session-timeout-dialog.component'; @@ -102,6 +103,7 @@ export class WebAppComponent implements OnInit, OnDestroy { private authSubscription: Subscription; private destroy$ = new Subject(); + private systemService = inject(SystemService); /** * @param {Router} router Router for navigation. @@ -254,6 +256,27 @@ export class WebAppComponent implements OnInit, OnDestroy { } // Set default max date picker as Today this.settingsService.setBusinessDate(this.dateUtils.formatDate(new Date(), SettingsService.businessDateFormat)); + // ...then replace it with the bank's own "today" (business date when enabled, server date when + // not) once signed in, and again whenever the business date or its on/off switch changes. + // isAuthenticated$ fires at the start of a fresh login, before the auth header is set - a call + // then gets a 401 Basic challenge and the browser's own sign-in box. So it only covers a restored + // session (credentials already saved); a fresh login is caught by 'Authentication Success'. + this.authenticationService.isAuthenticated$.pipe(takeUntil(this.destroy$)).subscribe((loggedIn) => { + if (loggedIn && this.authenticationService.isAuthenticated()) { + this.refreshBusinessToday(); + } + }); + this.alertService.alertEvent + .pipe( + filter( + (alertEvent: Alert) => + alertEvent.type === 'Authentication Success' || + alertEvent.type === SettingsService.businessDateType + ' Set' || + alertEvent.type === SettingsService.businessDateType + ' Set Config' + ), + takeUntil(this.destroy$) + ) + .subscribe(() => this.refreshBusinessToday()); // Set the server list from the env var FINERACT_API_URLS, but avoid overwriting "Add new server" user choice if (!this.settingsService.servers) { this.settingsService.setServers(environment.baseApiUrls.split(',')); @@ -298,6 +321,19 @@ export class WebAppComponent implements OnInit, OnDestroy { } } + /** Stores the server's "today" where every date default reads it (see core/utils/business-today). */ + private refreshBusinessToday(): void { + this.systemService.getBusinessToday().subscribe({ + next: (res) => { + if (res?.today) { + this.settingsService.setBusinessDate(res.today); + } + }, + // Unreachable server or older backend: keep whatever date is already stored. + error: () => {} + }); + } + ngOnDestroy() { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/environments/.env.ts b/src/environments/.env.ts index 3e34ed8a3..888a7a52b 100644 --- a/src/environments/.env.ts +++ b/src/environments/.env.ts @@ -9,8 +9,8 @@ /* tslint:disable */ export default { 'mifos_x': { - 'version': '261005', - 'hash': '1ffac4b1' + 'version': '261006', + 'hash': '5ab4e9ea' }, 'allow_switching_backend_instance': true };